feat: add AIMD adaptive concurrency pacing per source
P6 round 2 showed routing convergence turns account-level 429s into the binding constraint (62% throttle rate at full concurrency). Each source now carries a local AIMD limit: multiplicative cut on 429, additive growth on success. Over-limit picks queue via the existing quota-wait poll instead of burning retry budget or tripping the circuit-open verdict.
This commit is contained in:
@@ -8,7 +8,15 @@ SCOPE_REASONS = frozenset(
|
||||
{"circuit_open", "retry_exhausted", "stalled", "quota_exhausted", "no_sources"}
|
||||
)
|
||||
SOURCE_REASONS = frozenset(
|
||||
{"network_error", "timeout", "rate_limited", "source_dead", "circuit_open", "cooldown"}
|
||||
{
|
||||
"network_error",
|
||||
"timeout",
|
||||
"rate_limited",
|
||||
"source_dead",
|
||||
"circuit_open",
|
||||
"cooldown",
|
||||
"adaptive_paced", # M2.5 §3.35: AIMD 超限排队(quota-wait 通道)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from polygateway.errors import (
|
||||
from polygateway.middleware.breaker import BreakerGate
|
||||
from polygateway.middleware.ratelimit import QuotaGate
|
||||
from polygateway.ports import OutcomeAwareSelector
|
||||
from polygateway.sources import SourceCooldownMemo
|
||||
from polygateway.sources import AdaptivePacer, SourceCooldownMemo
|
||||
from polygateway.streaming import StreamLivenessTimeout
|
||||
from polygateway.types import LLMResponse
|
||||
|
||||
@@ -118,6 +118,7 @@ class RetryMW:
|
||||
backpressure: BackpressurePolicy,
|
||||
quota_full: str = "wait",
|
||||
cooldown_memo: SourceCooldownMemo | None = None,
|
||||
pacer: AdaptivePacer | None = None,
|
||||
emitter: object | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
@@ -137,6 +138,8 @@ class RetryMW:
|
||||
self._memo = cooldown_memo or SourceCooldownMemo(now=now)
|
||||
# M2.5: 选源器可选健康喂数端口,构造期 isinstance 判定一次(设计 §3.2)
|
||||
self._outcome_sink = selector if isinstance(selector, OutcomeAwareSelector) else None
|
||||
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
|
||||
self._pacer = pacer or AdaptivePacer(ceiling=64.0)
|
||||
self._emitter = emitter
|
||||
self._now = now
|
||||
self._sleep = sleep
|
||||
@@ -184,6 +187,10 @@ class RetryMW:
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "cooldown"
|
||||
continue
|
||||
if not self._pacer.admit(cand.name):
|
||||
# AIMD 超限: 不计 gate_rejections → 走 quota-wait 排队,不误判熔断
|
||||
reasons.setdefault(cand.name, "adaptive_paced")
|
||||
continue
|
||||
permit = await self._quota.try_acquire(cand)
|
||||
if permit is None:
|
||||
reasons.setdefault(cand.name, "rate_limited")
|
||||
@@ -196,6 +203,7 @@ class RetryMW:
|
||||
if entry is None:
|
||||
await self._settle_and_release(permit, 0)
|
||||
if entry.allowed:
|
||||
self._pacer.enter(cand.name)
|
||||
return (cand, permit, entry), gate_rejections
|
||||
gate_rejections += 1
|
||||
reasons[cand.name] = "circuit_open"
|
||||
@@ -261,6 +269,7 @@ class RetryMW:
|
||||
await self._record_quietly(self._breaker.record_success(entry))
|
||||
await self._record_quietly(self._quota.mark_progress())
|
||||
self._feed_outcome(source.name, ok=True)
|
||||
self._pacer.on_success(source.name)
|
||||
response = self._build_response(source, result, call_id, started)
|
||||
await self._emit(request, source, call_id, started, response=response)
|
||||
return response
|
||||
@@ -284,12 +293,15 @@ class RetryMW:
|
||||
reasons[source.name] = reason
|
||||
attempt_fails[source.name] = attempt_fails.get(source.name, 0) + 1
|
||||
self._feed_outcome(source.name, ok=False)
|
||||
if reason == "rate_limited":
|
||||
self._pacer.on_backpressure(source.name)
|
||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||
if not dead:
|
||||
actual = source.est_tokens # 保守: 失败请求可能已被网关计费(CHS 同款)
|
||||
await self._emit(request, source, call_id, started, error=exc)
|
||||
return _Failed(exc, immediate=dead)
|
||||
finally:
|
||||
self._pacer.leave(source.name)
|
||||
await self._settle_and_release(permit, actual)
|
||||
|
||||
async def _on_rejected(
|
||||
|
||||
@@ -82,6 +82,45 @@ class HealthAwareSelector:
|
||||
return [head] + [s for s in ranked if s.name != head.name]
|
||||
|
||||
|
||||
class AdaptivePacer:
|
||||
"""AIMD 自适应并发(M2.5 设计 §3.35;Netflix concurrency-limits 损失型)。
|
||||
|
||||
429 是网关的"降速"信号: 乘性削减该源并发上限(×0.7),真实成功加性
|
||||
增长(+1/limit),上限收敛到网关可持续水位;超限调用在 RetryMW 的
|
||||
quota-wait 轮询里排队而非烧重试预算。进程本地,属 client 实例。
|
||||
"""
|
||||
|
||||
_INITIAL = 8.0
|
||||
_CUT = 0.7
|
||||
_FLOOR = 1.0
|
||||
|
||||
def __init__(self, *, ceiling: float) -> None:
|
||||
if ceiling < self._FLOOR:
|
||||
raise ValueError("ceiling 不得小于下限 1")
|
||||
self._ceiling = ceiling
|
||||
self._limit: dict[str, float] = {}
|
||||
self._inflight: dict[str, int] = {}
|
||||
|
||||
def limit(self, source_name: str) -> float:
|
||||
return self._limit.get(source_name, min(self._INITIAL, self._ceiling))
|
||||
|
||||
def on_backpressure(self, source_name: str) -> None:
|
||||
self._limit[source_name] = max(self._FLOOR, self.limit(source_name) * self._CUT)
|
||||
|
||||
def on_success(self, source_name: str) -> None:
|
||||
cur = self.limit(source_name)
|
||||
self._limit[source_name] = min(self._ceiling, cur + 1.0 / cur)
|
||||
|
||||
def admit(self, source_name: str) -> bool:
|
||||
return self._inflight.get(source_name, 0) < self.limit(source_name)
|
||||
|
||||
def enter(self, source_name: str) -> None:
|
||||
self._inflight[source_name] = self._inflight.get(source_name, 0) + 1
|
||||
|
||||
def leave(self, source_name: str) -> None:
|
||||
self._inflight[source_name] = max(0, self._inflight.get(source_name, 0) - 1)
|
||||
|
||||
|
||||
class SourceCooldownMemo:
|
||||
"""进程本地的源冷却备忘(CHS governance.py:107 同款)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user