Merge "Add support for custom quota-exceeded messages"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/MaxConnectionsLimiter.java b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxConnectionsLimiter.java
index f2b18cd..a5245ec 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/MaxConnectionsLimiter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/MaxConnectionsLimiter.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.quota;
 
 import com.google.gerrit.httpd.AllRequestFilter;
@@ -7,10 +21,11 @@
 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.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.regex.Matcher;
@@ -21,6 +36,7 @@
 import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.lib.Config;
 
 @Singleton
 public class MaxConnectionsLimiter extends AllRequestFilter {
@@ -53,9 +69,11 @@
     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))));
+      if (val != null) {
+        Matcher matcher = REST_API_REGEX.matcher(val);
+        if (matcher.matches()) {
+          limits.add(new Limit(group, Integer.parseInt(matcher.group(1))));
+        }
       }
     }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/MinStartForQueueQuota.java b/src/main/java/com/googlesource/gerrit/plugins/quota/MinStartForQueueQuota.java
index 33b9a3f..68cdc1d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/MinStartForQueueQuota.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/MinStartForQueueQuota.java
@@ -14,15 +14,15 @@
 
 package com.googlesource.gerrit.plugins.quota;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.Optional;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class MinStartForQueueQuota {
   public static final Logger log = LoggerFactory.getLogger(MinStartForQueueQuota.class);
+  public static final String KEY = "minStartForQueue";
   // 10 SSH-Interactive-Worker
   public static final Pattern CONFIG_PATTERN = Pattern.compile("(\\d+)\\s+(.+)");
 
@@ -35,16 +35,17 @@
     }
 
     if (matcher.matches()) {
-      int limit = Integer.parseInt(matcher.group(1));
+      int reservation = Integer.parseInt(matcher.group(1));
       String queue = matcher.group(2);
       QueueManager.registerReservation(
           queue,
           new QueueManager.Reservation(
-              limit,
+              reservation,
               task -> {
                 return task.getQueueName().equalsIgnoreCase(queue)
                     && TaskQuotas.estimateProject(task).map(qs::matches).orElse(false);
-              }));
+              },
+              qs.getNamespace()));
     } else {
       log.error("Invalid configuration entry [{}]", cfg);
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/Module.java b/src/main/java/com/googlesource/gerrit/plugins/quota/Module.java
index 781787a..045801f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/Module.java
@@ -96,6 +96,9 @@
     bind(LifecycleListener.class)
         .annotatedWith(UniqueAnnotations.create())
         .to(PublisherScheduler.class);
+    bind(LifecycleListener.class)
+        .annotatedWith(UniqueAnnotations.create())
+        .to(TaskQuotaLogFile.class);
 
     DynamicSet.bind(binder(), UploadValidationListener.class).to(RateLimitUploadListener.class);
     bindConstant()
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSection.java b/src/main/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSection.java
index ad8a90c..7e7b516 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/NamespacedQuotaSection.java
@@ -47,9 +47,4 @@
   public String subSection() {
     return namespace();
   }
-
-  @Override
-  public boolean isFallbackQuota() {
-    return isFallBack;
-  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/ParkedQuotaTransitionLogger.java b/src/main/java/com/googlesource/gerrit/plugins/quota/ParkedQuotaTransitionLogger.java
new file mode 100644
index 0000000..4377188
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/ParkedQuotaTransitionLogger.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.quota;
+
+import static com.google.gerrit.server.git.WorkQueue.Task;
+import static com.googlesource.gerrit.plugins.quota.QueueManager.Queue;
+import static com.googlesource.gerrit.plugins.quota.QueueManager.QueueInfo;
+
+import com.google.gerrit.server.ioutil.HexFormat;
+import com.google.gerrit.util.logging.NamedFluentLogger;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class ParkedQuotaTransitionLogger {
+  protected static final NamedFluentLogger quotaLog =
+      NamedFluentLogger.forName(TaskQuotaLogFile.NAME);
+  protected static final Map<Integer, TaskQuota> prevParkingQuotaByTaskId =
+      new ConcurrentHashMap<>();
+  protected static final Map<Integer, Instant> parkedSince = new ConcurrentHashMap<>();
+  protected static final TaskQuota CANNOT_SATISFY_RESERVATIONS_QUOTA =
+      new TaskQuota() {
+        @Override
+        public boolean isApplicable(Task<?> task) {
+          throw new IllegalStateException();
+        }
+
+        @Override
+        public boolean isReadyToStart(Task<?> task) {
+          throw new IllegalStateException();
+        }
+
+        @Override
+        public void onStop(Task<?> task) {
+          throw new IllegalStateException();
+        }
+      };
+
+  /** Logs only if the reason for parked changes from the previous parking event. */
+  public static void logTaskWithEnforcedQuota(Task<?> t, TaskQuota q) {
+    parkedSince.putIfAbsent(t.getTaskId(), Instant.now());
+    if (shouldLog(t, q)) {
+      quotaLog.atInfo().log("Task [%s] parked due to quota rule [%s]", formatTask(t), q);
+    }
+  }
+
+  public static void logTaskWithNoSatisfyingReservation(Task<?> t) {
+    parkedSince.putIfAbsent(t.getTaskId(), Instant.now());
+    if (!shouldLog(t, CANNOT_SATISFY_RESERVATIONS_QUOTA)) {
+      return;
+    }
+
+    QueueInfo queueInfo = QueueManager.infoByQueue.get(Queue.fromKey(t.getQueueName()));
+    if (queueInfo != null) {
+      queueInfo.reservations.stream()
+          .filter(r -> r.matches(t))
+          .findFirst()
+          .ifPresentOrElse(
+              reservation -> {
+                quotaLog.atInfo().log(
+                    "Task [%s] parked because there are no spare unreserved threads in queue [%s], "
+                        + "and there are insufficient reserved threads for the %s namespace",
+                    formatTask(t), t.getQueueName(), reservation.namespace());
+              },
+              () -> {
+                quotaLog.atInfo().log(
+                    "Task [%s] parked because there are no spare unreserved threads in queue [%s]",
+                    formatTask(t), t.getQueueName());
+              });
+    }
+  }
+
+  public static void logOnTaskStartIfParked(Task<?> t) {
+    if (!prevParkingQuotaByTaskId.containsKey(t.getTaskId())) {
+      return;
+    }
+
+    quotaLog.atInfo().log(
+        "Task [%s] unparked after %d seconds",
+        formatTask(t), Duration.between(parkedSince.get(t.getTaskId()), Instant.now()).toSeconds());
+    clear(t);
+  }
+
+  public static boolean shouldLog(Task<?> t, TaskQuota q) {
+    return prevParkingQuotaByTaskId.put(t.getTaskId(), q) != q;
+  }
+
+  public static void clear(Task<?> t) {
+    prevParkingQuotaByTaskId.remove(t.getTaskId());
+    parkedSince.remove(t.getTaskId());
+  }
+
+  public static String formatTask(Task<?> t) {
+    return "%s: [%s]".formatted(HexFormat.fromInt(t.getTaskId()), t);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QueueManager.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QueueManager.java
index 4b0010d..a9ffb10 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/QueueManager.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QueueManager.java
@@ -15,19 +15,17 @@
 package com.googlesource.gerrit.plugins.quota;
 
 import com.google.gerrit.server.git.WorkQueue;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.function.Predicate;
-
-import java.util.*;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Predicate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class QueueManager {
   public static final Logger log = LoggerFactory.getLogger(QueueManager.class);
@@ -101,7 +99,8 @@
     }
   }
 
-  public record Reservation(int reservedCapacity, Predicate<WorkQueue.Task<?>> taskMatcher) {
+  public record Reservation(
+      int reservedCapacity, Predicate<WorkQueue.Task<?>> taskMatcher, String namespace) {
     public boolean matches(WorkQueue.Task<?> task) {
       return taskMatcher.test(task);
     }
@@ -164,7 +163,8 @@
           qName,
           reservation.reservedCapacity(),
           capacityToReserve);
-      queueInfo.addReservation(new Reservation(capacityToReserve, reservation.taskMatcher));
+      queueInfo.addReservation(
+          new Reservation(capacityToReserve, reservation.taskMatcher, reservation.namespace()));
       return;
     }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java
index 44d27c1..9d2d27b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/QuotaFinder.java
@@ -69,7 +69,7 @@
   }
 
   public QuotaSection getFallbackNamespacedQuota(Config cfg) {
-    return new NamespacedQuotaSection(cfg, "*", true);
+    return new NamespacedQuotaSection(cfg, "*");
   }
 
   public List<NamespacedQuotaSection> getQuotaNamespaces(Config cfg) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/SoftMaxPerUserForQueue.java b/src/main/java/com/googlesource/gerrit/plugins/quota/SoftMaxPerUserForQueue.java
index de7c630..19fa018 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/SoftMaxPerUserForQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/SoftMaxPerUserForQueue.java
@@ -24,14 +24,17 @@
 import java.util.regex.Pattern;
 
 public class SoftMaxPerUserForQueue implements TaskQuota {
+  public static final String KEY = "softMaxStartPerUserForQueue";
   public static final Pattern CONFIG_PATTERN =
       Pattern.compile("(\\d+)\\s+(" + String.join("|", QueueManager.Queue.keys()) + ")");
+  private final QuotaSection quotaSection;
   private final int softMax;
   private final QueueManager.Queue queue;
   private final ConcurrentHashMap<String, Integer> taskStartedCountByUser =
       new ConcurrentHashMap<>();
 
-  public SoftMaxPerUserForQueue(int softMax, String queueName) {
+  public SoftMaxPerUserForQueue(QuotaSection quotaSection, int softMax, String queueName) {
+    this.quotaSection = quotaSection;
     this.softMax = softMax;
     this.queue = QueueManager.Queue.fromKey(queueName);
   }
@@ -79,7 +82,14 @@
     Matcher matcher = CONFIG_PATTERN.matcher(cfg);
     return matcher.find()
         ? Optional.of(
-            new SoftMaxPerUserForQueue(Integer.parseInt(matcher.group(1)), matcher.group(2)))
+            new SoftMaxPerUserForQueue(qs, Integer.parseInt(matcher.group(1)), matcher.group(2)))
         : Optional.empty();
   }
+
+  @Override
+  public String toString() {
+    return KEY
+        + ": softMax [%d], queue [%s], namespace [%s]"
+            .formatted(softMax, queue.getName(), quotaSection.getNamespace());
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskParser.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskParser.java
index 732b082..5944308 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskParser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskParser.java
@@ -20,7 +20,7 @@
 import java.util.regex.Pattern;
 
 public class TaskParser {
-  public static final Pattern USER_EXTRACT_PATTERN = Pattern.compile("\\(([a-z0-9]+)\\)$");
+  public static final Pattern USER_EXTRACT_PATTERN = Pattern.compile("\\(([\\-_A-Za-z0-9]+)\\)$");
 
   public static Optional<String> user(WorkQueue.Task<?> task) {
     Matcher matcher = USER_EXTRACT_PATTERN.matcher(task.toString());
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTask.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTask.java
index 2d15abe..8375c3b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTask.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTask.java
@@ -21,7 +21,7 @@
 public abstract class TaskQuotaForTask extends TaskQuotaWithPermits {
   protected static final Map<String, Set<String>> SUPPORTED_TASKS_BY_GROUP =
       Map.of("uploadpack", Set.of("git-upload-pack"), "receivepack", Set.of("git-receive-pack"));
-  private final String taskGroup;
+  public final String taskGroup;
 
   public TaskQuotaForTask(String taskGroup, int permits) {
     super(permits);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java
index ac57a00..535edbf 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueue.java
@@ -23,13 +23,17 @@
 
 public class TaskQuotaForTaskForQueue extends TaskQuotaForTask {
   public static final Logger log = LoggerFactory.getLogger(TaskQuotaForTaskForQueue.class);
+  public static final String KEY = "maxStartForTaskForQueue";
   public static final Pattern CONFIG_PATTERN =
       Pattern.compile(
           "(\\d+)\\s+(" + String.join("|", SUPPORTED_TASKS_BY_GROUP.keySet()) + ")\\s+(.+)");
-  private final String queueName;
+  public final String queueName;
+  protected final QuotaSection quotaSection;
 
-  public TaskQuotaForTaskForQueue(String queueName, String taskGroup, int maxStart) {
+  public TaskQuotaForTaskForQueue(
+      QuotaSection quotaSection, String queueName, String taskGroup, int maxStart) {
     super(taskGroup, maxStart);
+    this.quotaSection = quotaSection;
     this.queueName = queueName;
   }
 
@@ -43,10 +47,17 @@
     if (matcher.matches()) {
       return Optional.of(
           new TaskQuotaForTaskForQueue(
-              matcher.group(3), matcher.group(2), Integer.parseInt(matcher.group(1))));
+              qs, matcher.group(3), matcher.group(2), Integer.parseInt(matcher.group(1))));
     } else {
       log.error("Invalid configuration entry [{}]", cfg);
       return Optional.empty();
     }
   }
+
+  @Override
+  public String toString() {
+    return KEY
+        + ": task [%s], queue [%s], permits [%d], namespace [%s]"
+            .formatted(taskGroup, queueName, maxPermits, quotaSection.getNamespace());
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueueForUser.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueueForUser.java
index 7afa870..751559e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueueForUser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaForTaskForQueueForUser.java
@@ -23,6 +23,7 @@
 
 public class TaskQuotaForTaskForQueueForUser extends TaskQuotaForTaskForQueue {
   public static final Logger log = LoggerFactory.getLogger(TaskQuotaForTaskForQueueForUser.class);
+  public static final String KEY = "maxStartForTaskForUserForQueue";
   public static final Pattern CONFIG_PATTERN =
       Pattern.compile(
           "(\\d+)\\s+("
@@ -33,8 +34,8 @@
   private final String user;
 
   public TaskQuotaForTaskForQueueForUser(
-      String queueName, String user, String taskGroup, int maxStart) {
-    super(queueName, taskGroup, maxStart);
+      QuotaSection quotaSection, String queueName, String user, String taskGroup, int maxStart) {
+    super(quotaSection, queueName, taskGroup, maxStart);
     this.user = user;
   }
 
@@ -49,6 +50,7 @@
     if (matcher.matches()) {
       return Optional.of(
           new TaskQuotaForTaskForQueueForUser(
+              qs,
               matcher.group(4),
               matcher.group(3),
               matcher.group(2),
@@ -58,4 +60,11 @@
       return Optional.empty();
     }
   }
+
+  @Override
+  public String toString() {
+    return KEY
+        + ": task [%s], queue [%s], user [%s], permits [%d], namespace [%s]"
+            .formatted(taskGroup, queueName, user, maxPermits, quotaSection.getNamespace());
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaKeys.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaKeys.java
index ff0a4cb..65da2a5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaKeys.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaKeys.java
@@ -18,13 +18,13 @@
 import java.util.function.BiFunction;
 
 public enum TaskQuotaKeys {
-  MAX_START_FOR_TASK_FOR_QUEUE("maxStartForTaskForQueue", TaskQuotaForTaskForQueue::build),
-  MIN_START_FOR_TASK_FOR_QUEUE("minStartForQueue", MinStartForQueueQuota::build),
+  MAX_START_FOR_TASK_FOR_QUEUE(TaskQuotaForTaskForQueue.KEY, TaskQuotaForTaskForQueue::build),
+  MIN_START_FOR_TASK_FOR_QUEUE(MinStartForQueueQuota.KEY, MinStartForQueueQuota::build),
   MAX_START_FOR_TASK_FOR_USER_FOR_QUEUE(
-      "maxStartForTaskForUserForQueue", TaskQuotaForTaskForQueueForUser::build),
+      TaskQuotaForTaskForQueueForUser.KEY, TaskQuotaForTaskForQueueForUser::build),
   MAX_START_PER_USER_FOR_TASK_FOR_QUEUE(
-      "maxStartPerUserForTaskForQueue", TaskQuotaPerUserForTaskForQueue::build),
-  SOFT_MAX_START_FOR_QUEUE_PER_USER("softMaxStartPerUserForQueue", SoftMaxPerUserForQueue::build);
+      TaskQuotaPerUserForTaskForQueue.KEY, TaskQuotaPerUserForTaskForQueue::build),
+  SOFT_MAX_START_FOR_QUEUE_PER_USER(SoftMaxPerUserForQueue.KEY, SoftMaxPerUserForQueue::build);
 
   public final String key;
   public final BiFunction<QuotaSection, String, Optional<TaskQuota>> processor;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaLogFile.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaLogFile.java
new file mode 100644
index 0000000..66c43ff
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaLogFile.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.quota;
+
+import com.google.gerrit.extensions.systemstatus.ServerInformation;
+import com.google.gerrit.server.util.PluginLogFile;
+import com.google.gerrit.server.util.SystemLog;
+import com.google.inject.Inject;
+import org.apache.log4j.PatternLayout;
+
+public class TaskQuotaLogFile extends PluginLogFile {
+  protected static String NAME = "quota_log";
+
+  @Inject
+  public TaskQuotaLogFile(SystemLog systemLog, ServerInformation serverInfo) {
+    super(systemLog, serverInfo, NAME, new PatternLayout("[%d] %m%n"));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaPerUserForTaskForQueue.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaPerUserForTaskForQueue.java
index a7299a2..dc2ee64 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaPerUserForTaskForQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaPerUserForTaskForQueue.java
@@ -22,10 +22,12 @@
 
 public class TaskQuotaPerUserForTaskForQueue extends TaskQuotaForTaskForQueue {
   public static final Logger log = LoggerFactory.getLogger(TaskQuotaPerUserForTaskForQueue.class);
+  public static final String KEY = "maxStartPerUserForTaskForQueue";
   private final PerUserTaskQuota perUserTaskQuota;
 
-  public TaskQuotaPerUserForTaskForQueue(String queue, String taskGroup, int maxStart) {
-    super(queue, taskGroup, maxStart);
+  public TaskQuotaPerUserForTaskForQueue(
+      QuotaSection quotaSection, String queue, String taskGroup, int maxStart) {
+    super(quotaSection, queue, taskGroup, maxStart);
     perUserTaskQuota = new PerUserTaskQuota(maxStart);
   }
 
@@ -44,10 +46,17 @@
     if (matcher.matches()) {
       return Optional.of(
           new TaskQuotaPerUserForTaskForQueue(
-              matcher.group(3), matcher.group(2), Integer.parseInt(matcher.group(1))));
+              qs, matcher.group(3), matcher.group(2), Integer.parseInt(matcher.group(1))));
     } else {
       log.error("Invalid configuration entry [{}]", cfg);
       return Optional.empty();
     }
   }
+
+  @Override
+  public String toString() {
+    return KEY
+        + ": task [%s], queue [%s], permits [%d], namespace [%s]"
+            .formatted(taskGroup, queueName, maxPermits, quotaSection.getNamespace());
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaWithPermits.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaWithPermits.java
index ea414ca..e4c6ade 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaWithPermits.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotaWithPermits.java
@@ -1,3 +1,17 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// 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.googlesource.gerrit.plugins.quota;
 
 import com.google.gerrit.server.git.WorkQueue;
@@ -5,9 +19,11 @@
 
 public abstract class TaskQuotaWithPermits implements TaskQuota {
   protected final AtomicInteger permits;
+  protected final int maxPermits;
 
   public TaskQuotaWithPermits(int maxPermits) {
     this.permits = new AtomicInteger(maxPermits);
+    this.maxPermits = maxPermits;
   }
 
   public boolean isReadyToStart(WorkQueue.Task<?> task) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
index 63a8bc1..41ea1c3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/quota/TaskQuotas.java
@@ -19,7 +19,11 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ThreadSettingsConfig;
 import com.google.gerrit.server.git.WorkQueue;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Function;
 import java.util.regex.Matcher;
@@ -28,12 +32,9 @@
 import javax.inject.Inject;
 import javax.inject.Singleton;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
 public class TaskQuotas implements WorkQueue.TaskParker {
-  private static final Logger log = LoggerFactory.getLogger(TaskQuotas.class);
   private final QuotaFinder quotaFinder;
   private final Map<Integer, List<TaskQuota>> quotasByTask = new ConcurrentHashMap<>();
   private final Map<QuotaSection, List<TaskQuota>> quotasByNamespace = new HashMap<>();
@@ -83,8 +84,8 @@
 
   @Override
   public boolean isReadyToStart(WorkQueue.Task<?> task) {
-    QueueManager.Queue queue = QueueManager.Queue.fromKey(task.getQueueName());
     if (!QueueManager.acquire(task)) {
+      ParkedQuotaTransitionLogger.logTaskWithNoSatisfyingReservation(task);
       return false;
     }
 
@@ -105,7 +106,7 @@
     for (TaskQuota quota : applicableQuotas) {
       if (quota.isApplicable(task)) {
         if (!quota.isReadyToStart(task)) {
-          log.debug("Task [{}] will be parked due task quota rules", task);
+          ParkedQuotaTransitionLogger.logTaskWithEnforcedQuota(task, quota);
           QueueManager.release(task);
           acquiredQuotas.forEach(q -> q.onStop(task));
           return false;
@@ -117,12 +118,15 @@
     if (!acquiredQuotas.isEmpty()) {
       quotasByTask.put(task.getTaskId(), acquiredQuotas);
     }
+
+    ParkedQuotaTransitionLogger.logOnTaskStartIfParked(task);
     return true;
   }
 
   @Override
   public void onNotReadyToStart(WorkQueue.Task<?> task) {
     QueueManager.release(task);
+    ParkedQuotaTransitionLogger.clear(task);
     Optional.ofNullable(quotasByTask.remove(task.getTaskId()))
         .ifPresent(quotas -> quotas.forEach(q -> q.onStop(task)));
   }
@@ -133,6 +137,7 @@
   @Override
   public void onStop(WorkQueue.Task<?> task) {
     QueueManager.release(task);
+    ParkedQuotaTransitionLogger.clear(task);
     Optional.ofNullable(quotasByTask.remove(task.getTaskId()))
         .ifPresent(quotas -> quotas.forEach(q -> q.onStop(task)));
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/QueueManagerTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/QueueManagerTest.java
index 1740561..2e247e2 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/quota/QueueManagerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/QueueManagerTest.java
@@ -14,12 +14,16 @@
 
 package com.googlesource.gerrit.plugins.quota;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.server.git.WorkQueue;
 import com.googlesource.gerrit.plugins.quota.QueueManager.Queue;
 import com.googlesource.gerrit.plugins.quota.QueueManager.QueueInfo;
-import com.google.gerrit.server.git.WorkQueue;
-import org.junit.Before;
-import org.junit.Test;
-
 import java.util.HashSet;
 import java.util.Set;
 import java.util.concurrent.CountDownLatch;
@@ -27,10 +31,8 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.IntStream;
-
-import static org.junit.Assert.*;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
+import org.junit.Before;
+import org.junit.Test;
 
 public class QueueManagerTest {
   private static final Queue TEST_QUEUE = Queue.INTERACTIVE;
diff --git a/src/test/java/com/googlesource/gerrit/plugins/quota/TaskQuotasTest.java b/src/test/java/com/googlesource/gerrit/plugins/quota/TaskQuotasTest.java
index 05d23d2..61614c8 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/quota/TaskQuotasTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/quota/TaskQuotasTest.java
@@ -14,10 +14,12 @@
 
 package com.googlesource.gerrit.plugins.quota;
 
-import static com.googlesource.gerrit.plugins.quota.QueueManager.Queue.*;
+import static com.googlesource.gerrit.plugins.quota.QueueManager.Queue.INTERACTIVE;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
 
 import com.google.gerrit.server.git.WorkQueue.Task;
 import java.util.Random;
@@ -32,6 +34,7 @@
 public class TaskQuotasTest {
   private static final String PROJECT_X = "project-x";
   private static final String USER_A = "USER_A";
+  private static final String USER_B = "USER_B";
 
   @Test
   public void testMaxStartForTaskForQueue() throws ConfigInvalidException {
@@ -39,7 +42,7 @@
         taskQuotas(
             2,
             2,
-"""
+            """
 [quota "%s"]
   maxStartForTaskForQueue = 1 uploadpack %s
 """
@@ -74,6 +77,61 @@
     startAndCompleteTask(taskQuotas, u_x_3);
   }
 
+  @Test
+  public void testSoftMaxPerUserForQueue() throws ConfigInvalidException {
+    TaskQuotas taskQuotas =
+        taskQuotas(
+            5,
+            5,
+            """
+[quota "%s"]
+  softMaxStartPerUserForQueue = 2 %s
+"""
+                .formatted(PROJECT_X, INTERACTIVE.getName()));
+
+    // running: user_a: 1 user_b: 0
+    Task<?> u_x_a_1 = task(INTERACTIVE.getName(), uploadPackTask(PROJECT_X, USER_A));
+    assertTrue(taskQuotas.isReadyToStart(u_x_a_1));
+    taskQuotas.onStart(u_x_a_1);
+
+    // running: user_a: 2 user_b: 0 (user_a is at the softMax)
+    Task<?> u_x_a_2 = task(INTERACTIVE.getName(), uploadPackTask(PROJECT_X, USER_A));
+    assertTrue(taskQuotas.isReadyToStart(u_x_a_2));
+    taskQuotas.onStart(u_x_a_2);
+
+    // running: user_a: 3 user_b: 0 (user_a able to start new task exceeding soft max)
+    Task<?> u_x_a_3 = task(INTERACTIVE.getName(), receivePackTask(PROJECT_X, USER_A));
+    assertTrue(taskQuotas.isReadyToStart(u_x_a_3));
+    taskQuotas.onStart(u_x_a_3);
+
+    // running: user_a: 3 user_b: 1
+    Task<?> u_x_b_1 = task(INTERACTIVE.getName(), uploadPackTask(PROJECT_X, USER_B));
+    assertTrue(taskQuotas.isReadyToStart(u_x_b_1));
+    taskQuotas.onStart(u_x_b_1);
+
+    // running: user_a: 3 user_b: 2
+    Task<?> u_x_b_2 = task(INTERACTIVE.getName(), receivePackTask(PROJECT_X, USER_B));
+    assertTrue(taskQuotas.isReadyToStart(u_x_b_2));
+    taskQuotas.onStart(u_x_b_2);
+
+    // running: user_a: 2 user_b: 2
+    taskQuotas.onStop(u_x_a_1);
+    Task<?> u_x_a_4 = task(INTERACTIVE.getName(), uploadPackTask(PROJECT_X, USER_A));
+    assertFalse(taskQuotas.isReadyToStart(u_x_a_4));
+
+    // running: user_a: 2 user_b: 1
+    taskQuotas.onStop(u_x_b_1);
+
+    // running: user_a: 3 user_b: 1
+    assertTrue(taskQuotas.isReadyToStart(u_x_a_4));
+    taskQuotas.onStart(u_x_a_4);
+
+    taskQuotas.onStop(u_x_a_2);
+    taskQuotas.onStop(u_x_a_3);
+    taskQuotas.onStop(u_x_a_4);
+    taskQuotas.onStop(u_x_b_2);
+  }
+
   private Task<?> task(String queueName, String taskString) {
     Task<?> task = Mockito.mock(Task.class);
     when(task.getTaskId()).thenReturn(new Random().nextInt());