Skip to content

Commit 5900470

Browse files
Reflexcursoragent
andcommitted
fix: randomize per-client H2 shard start offsets
Avoid every new SDK client landing first wait/upload on global shard 0 when transports are process-shared. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b6451ee commit 5900470

3 files changed

Lines changed: 36 additions & 14 deletions

File tree

src/runloop_api_client/_base_client.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import asyncio
99
import inspect
1010
import logging
11+
import secrets
1112
import weakref
1213
import platform
1314
import warnings
@@ -178,8 +179,8 @@ async def aclose(self) -> None:
178179

179180
# Sharded H2 bulkheads: long-polls and file transfers stay off the API control-plane
180181
# connection. Each shard index maps to its own shared transport (≈ one H2 connection).
181-
# Per-client round-robin spreads concurrent requests across shards. Removable once
182-
# httpcore respects stream capacity when opening connections.
182+
# Per-client round-robin (random start offset) spreads concurrent requests across
183+
# shards. Removable once httpcore respects stream capacity when opening connections.
183184
_shared_sync_background_transports: dict[int, _SharedTransport] = {}
184185
_shared_sync_transfer_transports: dict[int, _SharedTransport] = {}
185186
_shared_async_background_transports: weakref.WeakKeyDictionary[
@@ -1054,8 +1055,9 @@ def __init__(
10541055
self._bulkhead_lock = threading.Lock()
10551056
self._background_pool_shards = background_pool_shards
10561057
self._transfer_pool_shards = transfer_pool_shards
1057-
self._background_next = 0
1058-
self._transfer_next = 0
1058+
# Random start avoids every short-lived SDK client pinning global shard 0.
1059+
self._background_next = secrets.randbelow(background_pool_shards)
1060+
self._transfer_next = secrets.randbelow(transfer_pool_shards)
10591061
# Custom http_client owns the full transport stack; don't invent sibling pools.
10601062
self._isolate_workload_pools = http_client is None
10611063

@@ -1786,8 +1788,9 @@ def __init__(
17861788
self._transfer_clients = {}
17871789
self._background_pool_shards = background_pool_shards
17881790
self._transfer_pool_shards = transfer_pool_shards
1789-
self._background_next = 0
1790-
self._transfer_next = 0
1791+
# Random start avoids every short-lived SDK client pinning global shard 0.
1792+
self._background_next = secrets.randbelow(background_pool_shards)
1793+
self._transfer_next = secrets.randbelow(transfer_pool_shards)
17911794
# Custom http_client owns the full transport stack; don't invent sibling pools.
17921795
self._isolate_workload_pools = http_client is None
17931796

src/runloop_api_client/_client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ def __init__(
9898
# Set to False to create a private connection pool (old behavior).
9999
shared_http_pool: bool = True,
100100
# Separate H2 connection pools for long-polls (/wait_for_status) and file
101-
# transfers. Each shard ≈ one H2 connection; requests round-robin per client.
101+
# transfers. Each shard ≈ one H2 connection; requests round-robin per client
102+
# from a randomized starting offset (avoids every new client hitting shard 0).
102103
background_pool_shards: int = DEFAULT_BACKGROUND_POOL_SHARDS,
103104
transfer_pool_shards: int = DEFAULT_TRANSFER_POOL_SHARDS,
104105
# Enable or disable schema validation for data returned by the API.
@@ -406,7 +407,8 @@ def __init__(
406407
# Set to False to create a private connection pool (old behavior).
407408
shared_http_pool: bool = True,
408409
# Separate H2 connection pools for long-polls (/wait_for_status) and file
409-
# transfers. Each shard ≈ one H2 connection; requests round-robin per client.
410+
# transfers. Each shard ≈ one H2 connection; requests round-robin per client
411+
# from a randomized starting offset (avoids every new client hitting shard 0).
410412
background_pool_shards: int = DEFAULT_BACKGROUND_POOL_SHARDS,
411413
transfer_pool_shards: int = DEFAULT_TRANSFER_POOL_SHARDS,
412414
# Enable or disable schema validation for data returned by the API.

tests/test_transfer_client.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ def test_round_robin_spreads_requests_across_shards() -> None:
119119
client.close()
120120

121121

122+
def test_many_clients_first_requests_use_both_shards() -> None:
123+
clients = [_make_client(background_pool_shards=2) for _ in range(40)]
124+
try:
125+
req = httpx.Request("POST", f"{base_url}/v1/devboxes/dbx_1/wait_for_status")
126+
seen_transports = {id(c._send_client_for_request(req)._transport) for c in clients} # type: ignore[attr-defined]
127+
assert len(seen_transports) == 2
128+
assert set(_base_mod._shared_sync_background_transports) == {0, 1}
129+
finally:
130+
for c in clients:
131+
c.close()
132+
133+
122134
def test_custom_http_client_skips_isolation() -> None:
123135
custom = httpx.Client()
124136
client = _make_client(http_client=custom)
@@ -137,19 +149,24 @@ def test_round_robin_is_per_client_while_transports_are_shared() -> None:
137149
c2 = _make_client(background_pool_shards=2)
138150
try:
139151
req = httpx.Request("POST", f"{base_url}/v1/devboxes/dbx_shared/wait_for_status")
140-
# Each SDK client has its own counter; both start at shard 0.
152+
start1 = c1._background_next
153+
start2 = c2._background_next
141154
t1 = c1._send_client_for_request(req)
142155
t2 = c2._send_client_for_request(req)
143156
assert t1 is not t2
144-
assert t1._transport is t2._transport # type: ignore[attr-defined]
145-
assert c1._background_next == 1
146-
assert c2._background_next == 1
157+
# Same starting shard → same shared transport; different → different shards.
158+
if start1 % 2 == start2 % 2:
159+
assert t1._transport is t2._transport # type: ignore[attr-defined]
160+
else:
161+
assert t1._transport is not t2._transport # type: ignore[attr-defined]
162+
assert c1._background_next == start1 + 1
163+
assert c2._background_next == start2 + 1
147164

148165
# Advancing one client does not affect the other.
149166
t1b = c1._send_client_for_request(req)
150167
assert t1b is not t1
151-
assert c1._background_next == 2
152-
assert c2._background_next == 1
168+
assert c1._background_next == start1 + 2
169+
assert c2._background_next == start2 + 1
153170
finally:
154171
c1.close()
155172
c2.close()

0 commit comments

Comments
 (0)