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:
2026-07-21 10:38:48 -04:00
parent 910857fd13
commit 58061c7536
6 changed files with 157 additions and 2 deletions
+40
View File
@@ -72,3 +72,43 @@ class TestHealthAwareSelector:
def test_missing_stats_defaults_to_zero_inflight(self):
sel = HealthAwareSelector(rng=lambda: 0.0)
assert [s.name for s in sel.order([_src("s1")], {})] == ["s1"]
class TestAdaptivePacer:
"""AIMD 自适应并发(M2.5 设计 §3.35): 429 乘性削减,成功加性增长。"""
def test_initial_and_bounds(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
assert pacer.limit("s1") == pytest.approx(8.0)
for _ in range(200):
pacer.on_backpressure("s1")
assert pacer.limit("s1") == pytest.approx(1.0) # 下限 1
for _ in range(2000):
pacer.on_success("s1")
assert pacer.limit("s1") == pytest.approx(32.0) # 上限 = ceiling
def test_cut_and_growth_math(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
pacer.on_backpressure("s1")
assert pacer.limit("s1") == pytest.approx(8.0 * 0.7)
before = pacer.limit("s1")
pacer.on_success("s1")
assert pacer.limit("s1") == pytest.approx(before + 1.0 / before)
def test_inflight_gate(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
for _ in range(200):
pacer.on_backpressure("s1") # limit → 1
assert pacer.admit("s1") is True
pacer.enter("s1")
assert pacer.admit("s1") is False # 在途 1 ≥ limit 1
pacer.leave("s1")
assert pacer.admit("s1") is True
pacer.leave("s1") # 多余 leave 不下穿 0
assert pacer._inflight.get("s1", 0) == 0
+48
View File
@@ -98,6 +98,7 @@ def _harness(
global_limits=_NO_GLOBAL,
rng=lambda: 0.0,
selector=None,
pacer=None,
):
clock = clock or FakeClock()
limiter = InMemoryLimiter(
@@ -125,6 +126,7 @@ def _harness(
now=clock,
sleep=sleep,
rng=rng,
pacer=pacer,
)
return mw, limiter, gate, transport, sleep, clock
@@ -443,3 +445,49 @@ class TestM25Orchestration:
await mw(_REQ)
g = gate._gates["a"]
assert g.a0 + g.a1 == 0
class TestAdaptivePacing:
"""AIMD 接线(设计 §3.35): 429 收紧准入,paced 源等待而非烧预算。"""
async def test_paced_source_waits_without_consuming_budget(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
for _ in range(200):
pacer.on_backpressure("a") # limit → 1
pacer.enter("a") # 模拟一个在途占满名额
mw, _, _, transport, _, _ = _harness(
[_src("a")], [_ok()], quota_full="fail_fast", pacer=pacer
)
with pytest.raises(AllSourcesExhausted) as ei:
await mw(_REQ)
assert ei.value.reason == "quota_exhausted" # 走配额等待通道,非 CircuitOpen
assert ei.value.per_source_reasons.get("a") == "adaptive_paced"
assert transport.calls == [] # 未发起尝试 → 不烧重试预算
async def test_429_cuts_limit_success_grows_it(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
mw, _, _, _, _, _ = _harness(
[_src("a")],
[TransientError("throttled", status_code=429), _ok()],
pacer=pacer,
)
await mw(_REQ)
# 429 削减一次(8→5.6),随后成功加性增长(5.6 + 1/5.6)
assert pacer.limit("a") == pytest.approx(8.0 * 0.7 + 1.0 / (8.0 * 0.7))
async def test_inflight_returns_to_zero_after_call(self):
from polygateway.sources import AdaptivePacer
pacer = AdaptivePacer(ceiling=32.0)
mw, _, _, _, _, _ = _harness(
[_src("a"), _src("b")],
[TransientError("x"), _ok()],
pacer=pacer,
)
await mw(_REQ)
assert pacer._inflight.get("a", 0) == 0
assert pacer._inflight.get("b", 0) == 0