Skip to content

Commit 5c588d4

Browse files
committed
feat(async-index): restore batch_size and show_progress on AsyncIndex.upsert
## Purpose BCG-090 (severity H): the async `upsert()` method was missing the `batch_size` and `show_progress` parameters available on the sync `Index.upsert()`, leaving async users without automatic batching. ## Solution - Added `batch_size: int | None = None` and `show_progress: bool = True` parameters to `AsyncIndex.upsert()`. - Extracted the single-request logic into a private `async def _upsert_one_batch(...)` method. - When `batch_size` is set, vectors are split via `chunked()` and each batch is sent **sequentially** (one `await` at a time) to match sync semantics. Users who want parallel emission can fan out with `asyncio.gather()` themselves. - `response_info` is set to `None` on aggregated multi-batch responses; preserved on single-request responses. - Added 12 unit tests in `tests/unit/test_async_index_upsert_batching.py` mirroring the sync test suite, plus a sequential-emission contract test. ## Follow-ups `AsyncIndex.upsert_from_dataframe` remains `NotImplementedError` — that is a separate additive feature outside this task's scope.
1 parent abc09a3 commit 5c588d4

2 files changed

Lines changed: 300 additions & 3 deletions

File tree

pinecone/async_client/async_index.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from pinecone._internal.adapters.imports_adapter import ImportsAdapter
1515
from pinecone._internal.adapters.vectors_adapter import VectorsAdapter, extract_response_info
16+
from pinecone._internal.batching import chunked, validate_batch_size, with_progress
1617
from pinecone._internal.config import PineconeConfig
1718
from pinecone._internal.constants import DATA_PLANE_API_VERSION
1819
from pinecone._internal.data_plane_helpers import _validate_host, _vector_to_dict
@@ -221,6 +222,8 @@ async def upsert(
221222
| dict[str, Any]
222223
],
223224
namespace: str = "",
225+
batch_size: int | None = None,
226+
show_progress: bool = True,
224227
timeout: float | None = None,
225228
) -> UpsertResponse:
226229
"""Upsert a batch of vectors into a namespace.
@@ -235,13 +238,27 @@ async def upsert(
235238
and optional ``sparse_values`` / ``metadata`` keys.
236239
namespace (str): Target namespace. Defaults to the default
237240
(empty-string) namespace.
241+
batch_size (int | None): Split *vectors* into chunks of this size
242+
and send one request per chunk. Default ``None`` sends a single
243+
request (current behaviour). Must be a positive integer if
244+
provided.
245+
show_progress (bool): When ``True`` and ``tqdm`` is installed,
246+
display a progress bar across batches. Has no effect when
247+
``batch_size`` is ``None`` or ``tqdm`` is not installed.
248+
Defaults to ``True``.
249+
timeout (float | None): Per-request timeout in seconds. Overrides
250+
the client-level default for this call only.
238251
239252
Returns:
240253
:class:`UpsertResponse` with the count of vectors upserted.
254+
When ``batch_size`` triggers multiple requests, ``response_info``
255+
on the aggregated response is ``None`` — there is no single
256+
response to attribute it to.
241257
242258
Raises:
243259
:exc:`PineconeTypeError`: If a vector element is not a recognized format.
244260
:exc:`PineconeValueError`: If a vector element is malformed.
261+
:exc:`PineconeValueError`: If *batch_size* is not a positive integer.
245262
:exc:`ApiError`: If the API returns an error response.
246263
:exc:`PineconeConnectionError`: If a network-level connection
247264
fails (DNS, refused, transport error).
@@ -266,10 +283,18 @@ async def upsert(
266283
)
267284
print(response.upserted_count)
268285
286+
# Upsert 1000 vectors in batches of 100
287+
response = await idx.upsert(
288+
vectors=large_vector_list,
289+
batch_size=100,
290+
show_progress=True,
291+
)
292+
print(response.upserted_count)
293+
269294
.. note::
270-
All vectors are sent in a single request. For large datasets,
271-
batch your calls (recommended batch size: 100–500 vectors).
272-
For sync clients, use :meth:`upsert_from_dataframe` for automatic batching.
295+
Batches are sent **sequentially** (one ``await`` at a time). For
296+
parallel emission, fan out at the call site with
297+
``asyncio.gather(*[idx.upsert(vectors=b) for b in batches])``.
273298
For very large datasets (millions of vectors), consider
274299
:meth:`start_import` for bulk import from cloud storage.
275300
@@ -279,6 +304,33 @@ async def upsert(
279304
- :meth:`start_import` — for bulk loading millions of vectors
280305
from cloud storage (S3, GCS).
281306
"""
307+
if batch_size is None:
308+
return await self._upsert_one_batch(
309+
vectors=vectors, namespace=namespace, timeout=timeout
310+
)
311+
validate_batch_size(batch_size)
312+
built = list(vectors)
313+
batches = chunked(built, batch_size)
314+
total_count = 0
315+
for batch in with_progress(batches, show_progress=show_progress):
316+
result = await self._upsert_one_batch(
317+
vectors=batch, namespace=namespace, timeout=timeout
318+
)
319+
total_count += result.upserted_count
320+
return UpsertResponse(upserted_count=total_count)
321+
322+
async def _upsert_one_batch(
323+
self,
324+
*,
325+
vectors: Sequence[
326+
Vector
327+
| tuple[str, list[float]]
328+
| tuple[str, list[float], dict[str, Any]]
329+
| dict[str, Any]
330+
],
331+
namespace: str,
332+
timeout: float | None,
333+
) -> UpsertResponse:
282334
built = [VectorFactory.build(v) for v in vectors]
283335
body: dict[str, Any] = {
284336
"vectors": [_vector_to_dict(v) for v in built],
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Unit tests for AsyncIndex.upsert() batch_size and show_progress parameters."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from unittest.mock import patch
7+
8+
import httpx
9+
import pytest
10+
import respx
11+
12+
from pinecone import AsyncIndex
13+
from pinecone.errors.exceptions import PineconeValueError
14+
from pinecone.models.vectors.responses import UpsertResponse
15+
from pinecone.models.vectors.vector import Vector
16+
17+
INDEX_HOST = "test-index-abc1234.svc.us-east1-gcp.pinecone.io"
18+
INDEX_HOST_HTTPS = f"https://{INDEX_HOST}"
19+
UPSERT_URL = f"{INDEX_HOST_HTTPS}/vectors/upsert"
20+
21+
22+
def _make_upsert_response(*, upserted_count: int) -> dict[str, object]:
23+
return {"upsertedCount": upserted_count}
24+
25+
26+
def _make_async_index() -> AsyncIndex:
27+
return AsyncIndex(host=INDEX_HOST, api_key="test-key")
28+
29+
30+
def _make_vectors(n: int) -> list[Vector]:
31+
return [Vector(id=f"v{i}", values=[float(i), float(i + 1)]) for i in range(n)]
32+
33+
34+
class TestAsyncUpsertNoBatchSize:
35+
"""batch_size=None (default) sends a single request."""
36+
37+
@respx.mock
38+
@pytest.mark.asyncio
39+
async def test_upsert_no_batch_size_sends_single_request(self) -> None:
40+
route = respx.post(UPSERT_URL).mock(
41+
return_value=httpx.Response(200, json=_make_upsert_response(upserted_count=100))
42+
)
43+
idx = _make_async_index()
44+
result = await idx.upsert(vectors=_make_vectors(100), batch_size=None)
45+
assert len(route.calls) == 1
46+
assert result.upserted_count == 100
47+
48+
@respx.mock
49+
@pytest.mark.asyncio
50+
async def test_upsert_response_info_preserved_when_not_batched(self) -> None:
51+
respx.post(UPSERT_URL).mock(
52+
return_value=httpx.Response(
53+
200,
54+
json=_make_upsert_response(upserted_count=5),
55+
headers={"X-Pinecone-Request-Id": "req-abc123"},
56+
)
57+
)
58+
idx = _make_async_index()
59+
result = await idx.upsert(vectors=_make_vectors(5))
60+
assert result.response_info is not None
61+
62+
63+
class TestAsyncUpsertWithBatchSize:
64+
"""batch_size=N splits vectors and sends one request per chunk."""
65+
66+
@respx.mock
67+
@pytest.mark.asyncio
68+
async def test_upsert_with_batch_size_sends_multiple_requests(self) -> None:
69+
route = respx.post(UPSERT_URL).mock(
70+
side_effect=[
71+
httpx.Response(200, json=_make_upsert_response(upserted_count=100)),
72+
httpx.Response(200, json=_make_upsert_response(upserted_count=100)),
73+
httpx.Response(200, json=_make_upsert_response(upserted_count=50)),
74+
]
75+
)
76+
idx = _make_async_index()
77+
await idx.upsert(vectors=_make_vectors(250), batch_size=100, show_progress=False)
78+
assert len(route.calls) == 3
79+
80+
import orjson
81+
82+
first_body = orjson.loads(route.calls[0].request.content)
83+
second_body = orjson.loads(route.calls[1].request.content)
84+
third_body = orjson.loads(route.calls[2].request.content)
85+
assert len(first_body["vectors"]) == 100
86+
assert len(second_body["vectors"]) == 100
87+
assert len(third_body["vectors"]) == 50
88+
89+
@respx.mock
90+
@pytest.mark.asyncio
91+
async def test_upsert_with_batch_size_aggregates_response(self) -> None:
92+
respx.post(UPSERT_URL).mock(
93+
side_effect=[
94+
httpx.Response(200, json=_make_upsert_response(upserted_count=100)),
95+
httpx.Response(200, json=_make_upsert_response(upserted_count=100)),
96+
httpx.Response(200, json=_make_upsert_response(upserted_count=50)),
97+
]
98+
)
99+
idx = _make_async_index()
100+
result = await idx.upsert(vectors=_make_vectors(250), batch_size=100, show_progress=False)
101+
assert isinstance(result, UpsertResponse)
102+
assert result.upserted_count == 250
103+
104+
@respx.mock
105+
@pytest.mark.asyncio
106+
async def test_upsert_response_info_is_none_when_batched(self) -> None:
107+
respx.post(UPSERT_URL).mock(
108+
side_effect=[
109+
httpx.Response(200, json=_make_upsert_response(upserted_count=100)),
110+
httpx.Response(200, json=_make_upsert_response(upserted_count=100)),
111+
httpx.Response(200, json=_make_upsert_response(upserted_count=50)),
112+
]
113+
)
114+
idx = _make_async_index()
115+
result = await idx.upsert(vectors=_make_vectors(250), batch_size=100, show_progress=False)
116+
assert result.response_info is None
117+
118+
@respx.mock
119+
@pytest.mark.asyncio
120+
async def test_upsert_namespace_forwarded_per_batch(self) -> None:
121+
route = respx.post(UPSERT_URL).mock(
122+
side_effect=[
123+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
124+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
125+
]
126+
)
127+
idx = _make_async_index()
128+
await idx.upsert(
129+
vectors=_make_vectors(10), batch_size=5, namespace="my-ns", show_progress=False
130+
)
131+
132+
import orjson
133+
134+
for call in route.calls:
135+
body = orjson.loads(call.request.content)
136+
assert body.get("namespace") == "my-ns"
137+
138+
@respx.mock
139+
@pytest.mark.asyncio
140+
async def test_upsert_timeout_forwarded_per_batch(self) -> None:
141+
route = respx.post(UPSERT_URL).mock(
142+
side_effect=[
143+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
144+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
145+
]
146+
)
147+
idx = _make_async_index()
148+
await idx.upsert(vectors=_make_vectors(10), batch_size=5, timeout=5.0, show_progress=False)
149+
assert len(route.calls) == 2
150+
151+
@respx.mock
152+
@pytest.mark.asyncio
153+
async def test_upsert_empty_vectors_with_batch_size(self) -> None:
154+
route = respx.post(UPSERT_URL).mock(
155+
return_value=httpx.Response(200, json=_make_upsert_response(upserted_count=0))
156+
)
157+
idx = _make_async_index()
158+
result = await idx.upsert(vectors=[], batch_size=100, show_progress=False)
159+
assert len(route.calls) == 0
160+
assert result.upserted_count == 0
161+
162+
163+
class TestAsyncUpsertInvalidBatchSize:
164+
"""Invalid batch_size values raise PineconeValueError."""
165+
166+
@pytest.mark.asyncio
167+
async def test_upsert_invalid_batch_size_raises(self) -> None:
168+
idx = _make_async_index()
169+
with pytest.raises(PineconeValueError):
170+
await idx.upsert(vectors=_make_vectors(5), batch_size=0)
171+
172+
@pytest.mark.asyncio
173+
async def test_upsert_invalid_batch_size_negative(self) -> None:
174+
idx = _make_async_index()
175+
with pytest.raises(PineconeValueError):
176+
await idx.upsert(vectors=_make_vectors(5), batch_size=-1)
177+
178+
@pytest.mark.asyncio
179+
async def test_upsert_invalid_batch_size_float(self) -> None:
180+
idx = _make_async_index()
181+
with pytest.raises(PineconeValueError):
182+
await idx.upsert(vectors=_make_vectors(5), batch_size=1.5) # type: ignore[arg-type]
183+
184+
185+
class TestAsyncUpsertShowProgress:
186+
"""show_progress behavior with and without tqdm."""
187+
188+
@respx.mock
189+
@pytest.mark.asyncio
190+
async def test_upsert_show_progress_false_does_not_import_tqdm(
191+
self, monkeypatch: pytest.MonkeyPatch
192+
) -> None:
193+
monkeypatch.setitem(sys.modules, "tqdm", None) # type: ignore[arg-type]
194+
monkeypatch.setitem(sys.modules, "tqdm.auto", None) # type: ignore[arg-type]
195+
respx.post(UPSERT_URL).mock(
196+
side_effect=[
197+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
198+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
199+
]
200+
)
201+
idx = _make_async_index()
202+
result = await idx.upsert(vectors=_make_vectors(10), batch_size=5, show_progress=False)
203+
assert result.upserted_count == 10
204+
205+
@respx.mock
206+
@pytest.mark.asyncio
207+
async def test_upsert_show_progress_true_works_without_tqdm(
208+
self, monkeypatch: pytest.MonkeyPatch
209+
) -> None:
210+
monkeypatch.setitem(sys.modules, "tqdm", None) # type: ignore[arg-type]
211+
monkeypatch.setitem(sys.modules, "tqdm.auto", None) # type: ignore[arg-type]
212+
respx.post(UPSERT_URL).mock(
213+
side_effect=[
214+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
215+
httpx.Response(200, json=_make_upsert_response(upserted_count=5)),
216+
]
217+
)
218+
idx = _make_async_index()
219+
result = await idx.upsert(vectors=_make_vectors(10), batch_size=5, show_progress=True)
220+
assert result.upserted_count == 10
221+
222+
223+
class TestAsyncUpsertSequential:
224+
"""Batches are emitted sequentially, not in parallel."""
225+
226+
@pytest.mark.asyncio
227+
async def test_upsert_batches_are_sequential_not_parallel(self) -> None:
228+
call_order: list[int] = []
229+
230+
async def mock_upsert_one_batch(
231+
*,
232+
vectors: object,
233+
namespace: str,
234+
timeout: object,
235+
) -> UpsertResponse:
236+
batch_index = len(call_order)
237+
call_order.append(batch_index)
238+
return UpsertResponse(upserted_count=5)
239+
240+
idx = _make_async_index()
241+
with patch.object(idx, "_upsert_one_batch", side_effect=mock_upsert_one_batch):
242+
result = await idx.upsert(vectors=_make_vectors(15), batch_size=5, show_progress=False)
243+
244+
assert call_order == [0, 1, 2]
245+
assert result.upserted_count == 15

0 commit comments

Comments
 (0)