feat: self-tuning concurrency limit instead of a fixed Semaphore

Under 0.5-CPU containers, a static Semaphore(2000) never tripped —
latency ballooned to 1.5-2s instead of the service answering 429.
Runtime.availableProcessors() can't help pick a number either: it
ignores the cgroups --cpus quota and reports full host cores.

AdaptiveConcurrencyLimiter reacts to observed latency instead of
guessing capacity: starts at min-concurrent, grows by one per
adjustment window when latency stays under target, halves it the
moment it doesn't. Adjustment is gated by wall-clock time, not by
request count — an earlier per-request version let the limit race to
the ceiling in milliseconds under high RPS, before any real overload
had a chance to show up in the samples.

Verified under load (native image, 250MB/0.5 CPU): p50 latency at 3x
overload dropped from ~1.3s to under 4ms; normal-load p95 unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Максименко Никита Владимирович
2026-09-21 21:29:57 +03:00
co-authored by Claude Sonnet 5
parent c7e5b02362
commit 832738891c
4 changed files with 219 additions and 10 deletions
@@ -0,0 +1,72 @@
package ru.pdguard.core;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AdaptiveConcurrencyLimiterTest {
@Test
void startsAtMinimumAndRejectsAboveIt() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 10, 100);
assertTrue(limiter.tryAcquire());
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire(), "старт с минимума — сверх него запрос должен быть отклонён");
}
@Test
void growsToCeilingOnFastRequestsFromColdStart() {
// Окно регулировки — 0: каждый release должен считаться отдельным шагом,
// иначе тест либо ждёт реальные 20мс на шаг, либо не успевает ни разу сработать.
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 10, 100, 0);
long fast = TimeUnit.MILLISECONDS.toNanos(1);
for (int i = 0; i < 20; i++) {
limiter.tryAcquire();
limiter.release(fast);
}
assertEquals(10, limiter.limit(), "при быстрых запросах предел должен дорасти до потолка");
}
@Test
void shrinksTowardsMinimumWhenLatencyStaysAboveTarget() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 20, 50, 0);
long slow = TimeUnit.MILLISECONDS.toNanos(500);
limiter.tryAcquire();
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertTrue(limiter.limit() > 2, "предпосылка теста: предел должен был подрасти выше минимума");
for (int i = 0; i < 10; i++) {
limiter.tryAcquire();
limiter.release(slow);
}
assertEquals(2, limiter.limit(), "при стабильно высокой задержке предел должен сжаться до минимума");
}
@Test
void growsBackToCeilingWhenLatencyDropsBelowTarget() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(2, 20, 50, 0);
long slow = TimeUnit.MILLISECONDS.toNanos(500);
long fast = TimeUnit.MILLISECONDS.toNanos(1);
for (int i = 0; i < 10; i++) {
limiter.tryAcquire();
limiter.release(slow);
}
for (int i = 0; i < 20; i++) {
limiter.tryAcquire();
limiter.release(fast);
}
assertEquals(20, limiter.limit(), "при быстрой обработке предел должен вернуться к потолку");
}
@Test
void releaseFreesSlotForNextAcquire() {
AdaptiveConcurrencyLimiter limiter = new AdaptiveConcurrencyLimiter(1, 1, 1000);
assertTrue(limiter.tryAcquire());
assertFalse(limiter.tryAcquire(), "единственный слот занят");
limiter.release(TimeUnit.MILLISECONDS.toNanos(1));
assertTrue(limiter.tryAcquire(), "после release слот должен освободиться");
}
}