Skip to content

Commit e0a285c

Browse files
committed
feat(index): switch async AsyncIndex.upsert(batch_size=) to parallel via async_batch_execute (BC-0105)
Replace the sequential `for batch in with_progress(...): await ...` loop with `async_batch_execute` using an `asyncio.Semaphore` (default 4, range 1–64 via new `max_concurrency` kwarg). Adds `_upsert_dict_batch` private helper (pre-built items path) mirroring the sync `_upsert_dict_batch` from BC-0104. Failure contract changes to partial-success: per-batch errors are captured in the returned `UpsertResponse` instead of raising. Aggregate LSN headers are synthesised into `response_info` across successful batches. Tests: replace sequential test with concurrency/bounds-in-flight tests; add max_concurrency validation tests; add partial-failure and LSN-aggregation tests.
1 parent 9f66ccd commit e0a285c

2 files changed

Lines changed: 237 additions & 68 deletions

File tree

‎pinecone/async_client/async_index.py‎

Lines changed: 73 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@
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
16+
from pinecone._internal.batch import async_batch_execute
17+
from pinecone._internal.batching import validate_batch_size
1718
from pinecone._internal.config import PineconeConfig
1819
from pinecone._internal.constants import DATA_PLANE_API_VERSION
1920
from pinecone._internal.data_plane_helpers import _validate_host, _vector_to_dict
21+
from pinecone._internal.validation import require_in_range
2022
from pinecone._internal.vector_factory import VectorFactory
2123
from pinecone.errors.exceptions import PineconeValueError, ValidationError
2224
from pinecone.models.imports.list import ImportList
2325
from pinecone.models.imports.model import ImportModel, StartImportResponse
2426
from pinecone.models.namespaces.models import ListNamespacesResponse, NamespaceDescription
27+
from pinecone.models.response_info import ResponseInfo
2528
from pinecone.models.vectors.query_aggregator import QueryNamespacesResults, QueryResultsAggregator
2629
from pinecone.models.vectors.responses import (
2730
DescribeIndexStatsResponse,
@@ -224,6 +227,7 @@ async def upsert(
224227
namespace: str = "",
225228
batch_size: int | None = None,
226229
show_progress: bool = True,
230+
max_concurrency: int = 4,
227231
timeout: float | None = None,
228232
) -> UpsertResponse:
229233
"""Upsert a batch of vectors into a namespace.
@@ -246,19 +250,23 @@ async def upsert(
246250
display a progress bar across batches. Has no effect when
247251
``batch_size`` is ``None`` or ``tqdm`` is not installed.
248252
Defaults to ``True``.
253+
max_concurrency (int): Asyncio concurrency limit for concurrent batch
254+
requests (range 1–64, default 4). Only used when ``batch_size``
255+
is set.
249256
timeout (float | None): Per-request timeout in seconds. Overrides
250257
the client-level default for this call only.
251258
252259
Returns:
253260
:class:`UpsertResponse` with the count of vectors upserted.
254261
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.
262+
carries the aggregate LSN from all successful batches (or ``None``
263+
if no LSN headers were returned).
257264
258265
Raises:
259266
:exc:`PineconeTypeError`: If a vector element is not a recognized format.
260267
:exc:`PineconeValueError`: If a vector element is malformed.
261268
:exc:`PineconeValueError`: If *batch_size* is not a positive integer.
269+
:exc:`PineconeValueError`: If *max_concurrency* is outside [1, 64].
262270
:exc:`ApiError`: If the API returns an error response.
263271
:exc:`PineconeConnectionError`: If a network-level connection
264272
fails (DNS, refused, transport error).
@@ -292,11 +300,14 @@ async def upsert(
292300
print(response.upserted_count)
293301
294302
.. note::
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])``.
298-
For very large datasets (millions of vectors), consider
299-
:meth:`start_import` for bulk import from cloud storage.
303+
When ``batch_size`` is set, batches are submitted **concurrently** via an
304+
``asyncio.Semaphore`` of ``max_concurrency`` slots (default 4, range 1–64).
305+
Per-batch HTTP retries are handled by the client's configured
306+
``RetryConfig``. **Partial failures do not raise** — per-batch errors are
307+
captured on the returned :class:`UpsertResponse` (see
308+
``response.has_errors``, ``response.errors``, ``response.failed_items``).
309+
To retry only the failures, pass ``response.failed_items`` back to
310+
``upsert(...)``.
300311
301312
.. seealso::
302313
- :meth:`upsert_records` — for indexes with integrated inference
@@ -308,16 +319,46 @@ async def upsert(
308319
return await self._upsert_one_batch(
309320
vectors=vectors, namespace=namespace, timeout=timeout
310321
)
322+
311323
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)
324+
require_in_range("max_concurrency", max_concurrency, 1, 64)
325+
326+
built = [VectorFactory.build(v) for v in vectors]
327+
items: list[dict[str, Any]] = [_vector_to_dict(v) for v in built]
328+
329+
async def _operation(chunk: list[dict[str, Any]]) -> UpsertResponse:
330+
return await self._upsert_dict_batch(items=chunk, namespace=namespace, timeout=timeout)
331+
332+
batch_result = await async_batch_execute(
333+
items=items,
334+
operation=_operation,
335+
batch_size=batch_size,
336+
max_concurrency=max_concurrency,
337+
show_progress=show_progress,
338+
desc="Upserting",
339+
)
340+
341+
synth_headers: dict[str, str] = {}
342+
if batch_result.response_info is not None:
343+
if batch_result.response_info.lsn_reconciled is not None:
344+
synth_headers["x-pinecone-lsn-reconciled"] = str(
345+
batch_result.response_info.lsn_reconciled
346+
)
347+
if batch_result.response_info.lsn_committed is not None:
348+
synth_headers["x-pinecone-lsn-committed"] = str(
349+
batch_result.response_info.lsn_committed
350+
)
351+
synth_response_info = ResponseInfo(raw_headers=synth_headers) if synth_headers else None
352+
return UpsertResponse(
353+
upserted_count=batch_result.successful_item_count,
354+
response_info=synth_response_info,
355+
total_item_count=batch_result.total_item_count,
356+
failed_item_count=batch_result.failed_item_count,
357+
total_batch_count=batch_result.total_batch_count,
358+
successful_batch_count=batch_result.successful_batch_count,
359+
failed_batch_count=batch_result.failed_batch_count,
360+
errors=batch_result.errors,
361+
)
321362

322363
async def _upsert_one_batch(
323364
self,
@@ -345,6 +386,21 @@ async def _upsert_one_batch(
345386
logger.debug("Upserted %d vectors", result.upserted_count)
346387
return result
347388

389+
async def _upsert_dict_batch(
390+
self,
391+
*,
392+
items: list[dict[str, Any]],
393+
namespace: str,
394+
timeout: float | None,
395+
) -> UpsertResponse:
396+
body: dict[str, Any] = {"vectors": items}
397+
if namespace:
398+
body["namespace"] = namespace
399+
response = await self._http.post("/vectors/upsert", timeout=timeout, json=body)
400+
result = self._adapter.to_upsert_response(response.content)
401+
result.response_info = extract_response_info(response)
402+
return result
403+
348404
async def upsert_from_dataframe(
349405
self,
350406
df: pd.DataFrame,

0 commit comments

Comments
 (0)