Skip to content

Commit f23ceeb

Browse files
committed
feat(adaptive): add _AdaptiveLimiter and _AdaptiveLimiterRegistry (AIMD)
## Purpose Bulk paths need to self-tune effective concurrency in response to throttling (429s) without exposing new public config. This implements Phase C of the retry-resilience plan: per-host AIMD state managed internally by the SDK. ## Solution New module `pinecone/_internal/adaptive.py` with two classes: - `_AdaptiveLimiter`: per-host AIMD state with `threading.Lock`. Multiplicative decrease on throttle (`limit = max(1, limit // 2)`), additive increase after `current_limit` consecutive successes. - `_AdaptiveLimiterRegistry`: per-client dict with lazy creation and `report_throttled(host)` shortcut for transport callbacks. Hardcoded AIMD parameters (no public config). Thread-safe via stdlib lock; critical sections are O(1) so no async lock needed. ## Follow-ups Wire registry into bulk paths (Phase C2/C3) and transport callbacks (Phase C4/C5) in subsequent tasks (DX-0157, DX-0158, etc.).
1 parent 25b80c2 commit f23ceeb

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

pinecone/_internal/adaptive.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Per-host adaptive concurrency limiter (AIMD).
2+
3+
Internal to the SDK. The transport calls ``report_throttled(host)`` on every
4+
retryable response; the bulk paths read ``current_limit(host, ceiling)``
5+
before dispatching work. The limiter self-tunes effective concurrency
6+
between ``1`` and the user-provided ``max_concurrency`` ceiling.
7+
8+
Not thread-coordinated across processes. See ``docs/guides/retries.md``
9+
for multi-process guidance.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import threading
15+
from typing import TYPE_CHECKING
16+
17+
if TYPE_CHECKING:
18+
pass
19+
20+
21+
class _AdaptiveLimiter:
22+
"""AIMD state for a single host.
23+
24+
Multiplicative decrease on throttle: ``limit = max(1, limit // 2)``.
25+
Additive increase on success: ``limit = min(ceiling, limit + 1)``
26+
after ``current_limit`` consecutive successes. The success counter
27+
resets on each throttle.
28+
"""
29+
30+
__slots__ = ("_ceiling", "_limit", "_lock", "_success_streak")
31+
32+
def __init__(self, ceiling: int) -> None:
33+
if ceiling < 1:
34+
raise ValueError(f"ceiling must be >= 1, got {ceiling}")
35+
self._lock = threading.Lock()
36+
self._ceiling = ceiling
37+
self._limit = ceiling
38+
self._success_streak = 0
39+
40+
@property
41+
def ceiling(self) -> int:
42+
return self._ceiling
43+
44+
def current_limit(self) -> int:
45+
"""Return the current effective concurrency limit (1 <= limit <= ceiling)."""
46+
return self._limit
47+
48+
def report_throttled(self) -> None:
49+
"""Halve the limit (floored at 1) and reset the success streak."""
50+
with self._lock:
51+
self._limit = max(1, self._limit // 2)
52+
self._success_streak = 0
53+
54+
def report_success(self) -> None:
55+
"""Increment the success streak; bump limit by 1 if streak hits current limit."""
56+
with self._lock:
57+
self._success_streak += 1
58+
if self._success_streak >= self._limit:
59+
self._limit = min(self._ceiling, self._limit + 1)
60+
self._success_streak = 0
61+
62+
def update_ceiling(self, ceiling: int) -> None:
63+
"""Re-anchor the ceiling (e.g., a later bulk call uses a different max_concurrency).
64+
65+
Clamps the current limit to the new ceiling. Never raises the limit
66+
beyond what AIMD has earned — only the ceiling moves.
67+
"""
68+
if ceiling < 1:
69+
raise ValueError(f"ceiling must be >= 1, got {ceiling}")
70+
with self._lock:
71+
self._ceiling = ceiling
72+
if self._limit > ceiling:
73+
self._limit = ceiling
74+
75+
76+
class _AdaptiveLimiterRegistry:
77+
"""Per-client ``dict[host, _AdaptiveLimiter]`` with on-demand creation.
78+
79+
One instance lives on each ``Pinecone`` / ``AsyncPinecone`` client. The
80+
transport's ``on_throttle`` callback calls ``report_throttled(host)``;
81+
the bulk path calls ``get(host, ceiling).current_limit()`` before
82+
each batch dispatch.
83+
"""
84+
85+
__slots__ = ("_limiters", "_lock")
86+
87+
def __init__(self) -> None:
88+
self._lock = threading.Lock()
89+
self._limiters: dict[str, _AdaptiveLimiter] = {}
90+
91+
def get(self, host: str, ceiling: int) -> _AdaptiveLimiter:
92+
"""Return the limiter for ``host``, creating one with ``ceiling`` if absent.
93+
94+
If a limiter already exists with a different ceiling, the existing
95+
limiter's ceiling is updated to the new value (current limit
96+
stays unchanged unless it exceeds the new ceiling).
97+
"""
98+
with self._lock:
99+
limiter = self._limiters.get(host)
100+
if limiter is None:
101+
limiter = _AdaptiveLimiter(ceiling)
102+
self._limiters[host] = limiter
103+
elif limiter.ceiling != ceiling:
104+
limiter.update_ceiling(ceiling)
105+
return limiter
106+
107+
def report_throttled(self, host: str) -> None:
108+
"""Convenience: look up the limiter for ``host`` and decrement.
109+
110+
If no limiter exists for the host yet (e.g., throttle arrived before
111+
any bulk call set a ceiling), this is a no-op. The bulk path will
112+
create one with the right ceiling on its first call.
113+
"""
114+
with self._lock:
115+
limiter = self._limiters.get(host)
116+
if limiter is not None:
117+
limiter.report_throttled()
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from pinecone._internal.adaptive import _AdaptiveLimiter, _AdaptiveLimiterRegistry
6+
7+
8+
class TestAdaptiveLimiter:
9+
def test_initial_limit_is_ceiling(self) -> None:
10+
lim = _AdaptiveLimiter(ceiling=10)
11+
assert lim.current_limit() == 10
12+
assert lim.ceiling == 10
13+
14+
def test_throttle_halves_limit(self) -> None:
15+
lim = _AdaptiveLimiter(ceiling=10)
16+
lim.report_throttled()
17+
assert lim.current_limit() == 5
18+
19+
def test_throttle_floors_at_one(self) -> None:
20+
lim = _AdaptiveLimiter(ceiling=2)
21+
lim.report_throttled() # 2 → 1
22+
lim.report_throttled() # 1 → 1 (floor)
23+
lim.report_throttled()
24+
assert lim.current_limit() == 1
25+
26+
def test_success_streak_increases_limit(self) -> None:
27+
lim = _AdaptiveLimiter(ceiling=10)
28+
lim.report_throttled() # 10 → 5
29+
for _ in range(5):
30+
lim.report_success()
31+
assert lim.current_limit() == 6
32+
33+
def test_success_streak_resets_on_throttle(self) -> None:
34+
lim = _AdaptiveLimiter(ceiling=10)
35+
lim.report_throttled() # 10 → 5
36+
for _ in range(3):
37+
lim.report_success()
38+
lim.report_throttled() # 5 → 2; streak reset
39+
for _ in range(2):
40+
lim.report_success()
41+
# Streak hit 2 → limit becomes 3 (not 4 — streak was reset)
42+
assert lim.current_limit() == 3
43+
44+
def test_increase_caps_at_ceiling(self) -> None:
45+
lim = _AdaptiveLimiter(ceiling=2)
46+
# No throttle; just lots of successes
47+
for _ in range(100):
48+
lim.report_success()
49+
assert lim.current_limit() == 2
50+
51+
def test_update_ceiling_clamps_limit(self) -> None:
52+
lim = _AdaptiveLimiter(ceiling=10)
53+
assert lim.current_limit() == 10
54+
lim.update_ceiling(3)
55+
assert lim.ceiling == 3
56+
assert lim.current_limit() == 3
57+
58+
def test_update_ceiling_does_not_raise_limit(self) -> None:
59+
lim = _AdaptiveLimiter(ceiling=10)
60+
lim.report_throttled() # 10 → 5
61+
lim.update_ceiling(20) # ceiling moves up
62+
assert lim.ceiling == 20
63+
assert lim.current_limit() == 5 # AIMD-earned limit unchanged
64+
65+
def test_invalid_ceiling_raises(self) -> None:
66+
with pytest.raises(ValueError):
67+
_AdaptiveLimiter(ceiling=0)
68+
with pytest.raises(ValueError):
69+
_AdaptiveLimiter(ceiling=-1)
70+
71+
72+
class TestAdaptiveLimiterRegistry:
73+
def test_get_creates_limiter_on_first_call(self) -> None:
74+
reg = _AdaptiveLimiterRegistry()
75+
lim = reg.get("host-a.pinecone.io", ceiling=8)
76+
assert lim.ceiling == 8
77+
assert lim.current_limit() == 8
78+
79+
def test_get_returns_same_instance_for_same_host(self) -> None:
80+
reg = _AdaptiveLimiterRegistry()
81+
a1 = reg.get("host-a.pinecone.io", ceiling=8)
82+
a2 = reg.get("host-a.pinecone.io", ceiling=8)
83+
assert a1 is a2
84+
85+
def test_get_isolates_hosts(self) -> None:
86+
reg = _AdaptiveLimiterRegistry()
87+
a = reg.get("host-a.pinecone.io", ceiling=8)
88+
b = reg.get("host-b.pinecone.io", ceiling=8)
89+
assert a is not b
90+
a.report_throttled()
91+
assert a.current_limit() == 4
92+
assert b.current_limit() == 8
93+
94+
def test_report_throttled_on_unknown_host_is_noop(self) -> None:
95+
reg = _AdaptiveLimiterRegistry()
96+
# Should not raise
97+
reg.report_throttled("unknown-host.pinecone.io")
98+
99+
def test_get_with_different_ceiling_updates_existing(self) -> None:
100+
reg = _AdaptiveLimiterRegistry()
101+
a = reg.get("host-a.pinecone.io", ceiling=8)
102+
a.report_throttled() # 8 → 4
103+
a2 = reg.get("host-a.pinecone.io", ceiling=16)
104+
assert a is a2
105+
assert a.ceiling == 16
106+
assert a.current_limit() == 4 # AIMD-earned limit unchanged
107+
108+
def test_concurrent_access_invariants(self) -> None:
109+
"""Spawn N threads alternately throttling and reporting success;
110+
assert no exceptions and 1 <= current_limit() <= ceiling throughout.
111+
112+
Smoke-level concurrent-access check — not Hypothesis-grade — but it
113+
catches obvious failures (race on ``_limit``, missed ``notify``,
114+
silent ``AssertionError`` swallowing, deadlocks).
115+
"""
116+
import threading
117+
import time
118+
119+
reg = _AdaptiveLimiterRegistry()
120+
lim = reg.get("test-host", ceiling=16)
121+
errors: list[BaseException] = []
122+
stop = threading.Event()
123+
124+
def thrash(action: str) -> None:
125+
try:
126+
while not stop.is_set():
127+
if action == "throttle":
128+
lim.report_throttled()
129+
else:
130+
lim.report_success()
131+
cur = lim.current_limit()
132+
assert 1 <= cur <= 16, f"limit out of bounds: {cur}"
133+
except BaseException as e:
134+
errors.append(e)
135+
136+
threads = [threading.Thread(target=thrash, args=("throttle",)) for _ in range(4)]
137+
threads += [threading.Thread(target=thrash, args=("success",)) for _ in range(4)]
138+
for t in threads:
139+
t.start()
140+
time.sleep(0.5)
141+
stop.set()
142+
for t in threads:
143+
t.join(timeout=2.0)
144+
assert not t.is_alive(), "thread did not exit"
145+
assert not errors, f"errors during concurrent access: {errors}"

0 commit comments

Comments
 (0)