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
@@ -15,10 +15,9 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import ru.pdguard.config.SystemPolicy;
import ru.pdguard.config.SystemsConfig;
import ru.pdguard.core.AdaptiveConcurrencyLimiter;
import ru.pdguard.core.Pipeline;
import java.util.concurrent.Semaphore;
/**
* Единственная точка входа контракта: маскирование и демаскирование по
* {@code payload_id}.
@@ -28,8 +27,10 @@ import java.util.concurrent.Semaphore;
* контракт работает и без него. Система, выключенная в настройках, получает
* {@code 403}.
*
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After} вместо того,
* чтобы копить запросы и упереться в таймаут вызывающей стороны.
* <p>При перегрузке отвечает {@code 429} с {@code Retry-After}. Порог перегрузки —
* не фиксированное число запросов, а задержка обработки: {@link AdaptiveConcurrencyLimiter}
* сам находит потолок конкурентности под то, сколько CPU реально досталось контейнеру,
* вместо того чтобы копить запросы и упереться в таймаут вызывающей стороны.
*/
@Path("/process")
public class ProcessResource {
@@ -49,20 +50,25 @@ public class ProcessResource {
private final Pipeline pipeline;
private final SystemsConfig systems;
private final Semaphore permits;
private final AdaptiveConcurrencyLimiter limiter;
private final Counter rejected;
private final Counter malformed;
private final Counter forbidden;
public ProcessResource(Pipeline pipeline, SystemsConfig systems, MeterRegistry meters,
@ConfigProperty(name = "pdguard.min-concurrent", defaultValue = "8")
int minConcurrent,
@ConfigProperty(name = "pdguard.max-concurrent", defaultValue = "2000")
int maxConcurrent) {
int maxConcurrent,
@ConfigProperty(name = "pdguard.target-latency-ms", defaultValue = "200")
long targetLatencyMillis) {
this.pipeline = pipeline;
this.systems = systems;
this.permits = new Semaphore(maxConcurrent);
this.limiter = new AdaptiveConcurrencyLimiter(minConcurrent, maxConcurrent, targetLatencyMillis);
this.rejected = meters.counter("pdguard.requests.rejected", "reason", "overload");
this.malformed = meters.counter("pdguard.requests.rejected", "reason", "malformed");
this.forbidden = meters.counter("pdguard.requests.rejected", "reason", "system_disabled");
meters.gauge("pdguard.concurrency.limit", limiter, AdaptiveConcurrencyLimiter::limit);
}
@POST
@@ -87,10 +93,11 @@ public class ProcessResource {
.build();
}
if (!permits.tryAcquire()) {
if (!limiter.tryAcquire()) {
rejected.increment();
return Response.status(429).header("Retry-After", "1").build();
}
long started = System.nanoTime();
try {
String result = pipeline.process(request.payload(), request.payloadId(), policy);
return Response.ok(new ProcessResponse(result)).build();
@@ -101,7 +108,7 @@ public class ProcessResource {
request.payloadId());
return Response.ok(new ProcessResponse(request.payload())).build();
} finally {
permits.release();
limiter.release(System.nanoTime() - started);
}
}
}