Skip to content

Commit 2f90327

Browse files
committed
perf: validate and serialize requests in a single pass to cut peak memory
1 parent e943640 commit 2f90327

1 file changed

Lines changed: 44 additions & 49 deletions

File tree

src/apify_client/_resource_clients/request_queue.py

Lines changed: 44 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,37 @@
5858
from apify_client.types import Timeout
5959

6060
_RQ_MAX_REQUESTS_PER_BATCH = 25
61-
_MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024 # 9 MB
62-
_SAFETY_BUFFER_PERCENT = 0.01 / 100 # 0.01%
61+
"""Maximum number of requests the API accepts in a single batch call."""
6362

63+
_MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024
64+
"""Maximum payload size (9 MB) the API accepts for a single batch call."""
6465

65-
def _serialize_request(request: dict) -> bytes:
66-
"""Serialize a request into the JSON bytes it will occupy in the batch request body.
66+
_SAFETY_BUFFER_PERCENT = 0.01 / 100
67+
"""Safety margin (0.01%) deducted from the maximum payload size when splitting requests into batches."""
68+
69+
70+
def _serialize_requests(
71+
requests: list[RequestDraft] | list[RequestDraftDict] | list[RequestDraftCamelDict],
72+
) -> list[bytes]:
73+
"""Validate requests and serialize each one into the JSON bytes it will occupy in the batch request body.
6774
6875
Each request is serialized exactly once: the same bytes are measured when splitting requests into batches and
69-
then assembled into the request body, so batch sizes are computed on exactly the bytes that get sent. Uses the
70-
same `json.dumps` options as `HttpClientBase._prepare_request_call` to keep the wire format consistent with
71-
other endpoints.
76+
then assembled into the request body, so batch sizes are computed on exactly the bytes that get sent. Validation
77+
and serialization happen in a single pass, so each intermediate dict stays transient instead of a whole dict list
78+
being held in memory alongside the serialized requests. Uses the same `json.dumps` options as
79+
`HttpClientBase._prepare_request_call` to keep the wire format consistent with other endpoints.
7280
"""
73-
return json.dumps(request, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8')
81+
return [
82+
json.dumps(
83+
(request if isinstance(request, RequestDraft) else RequestDraft.model_validate(request)).model_dump(
84+
by_alias=True, exclude_none=True
85+
),
86+
ensure_ascii=False,
87+
allow_nan=False,
88+
default=str,
89+
).encode('utf-8')
90+
for request in requests
91+
]
7492

7593

7694
@docs_group('Resource clients')
@@ -412,23 +430,16 @@ def batch_add_requests(
412430
if max_parallel != 1:
413431
raise NotImplementedError('max_parallel is only supported in async client')
414432

415-
requests_as_dicts = [
416-
(r if isinstance(r, RequestDraft) else RequestDraft.model_validate(r)).model_dump(
417-
by_alias=True, exclude_none=True
418-
)
419-
for r in requests
420-
]
421-
422-
serialized_requests = [_serialize_request(r) for r in requests_as_dicts]
433+
# Validate the requests and serialize each of them into JSON bytes.
434+
serialized_requests = _serialize_requests(requests)
423435

436+
# Build the query parameters shared by all the batch API calls.
424437
request_params = self._build_params(clientKey=self.client_key, forefront=forefront)
425438

426439
# Compute the payload size limit to ensure it doesn't exceed the maximum allowed size.
427440
payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math.ceil(_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT)
428441

429-
# Split the requests into batches, constrained by the max payload size and max requests per batch. Each
430-
# request costs its serialized bytes plus a separator, and the brackets are reserved up front, so an
431-
# assembled `[...]` body can never exceed the limit.
442+
# Split the requests into batches by payload size (counting commas and brackets) and max requests per batch.
432443
batches = constrained_batches(
433444
serialized_requests,
434445
max_size=payload_size_limit_bytes - len(b'[]'),
@@ -438,17 +449,17 @@ def batch_add_requests(
438449
)
439450

440451
# Put the batches into the queue for processing.
441-
queue = Queue[Iterable[bytes]]()
452+
batch_queue = Queue[Iterable[bytes]]()
442453

443454
for batch in batches:
444-
queue.put(batch)
455+
batch_queue.put(batch)
445456

446457
processed_requests = list[AddedRequest]()
447458
unprocessed_requests = list[RequestDraft]()
448459

449460
# Process all batches in the queue sequentially.
450-
while not queue.empty():
451-
request_batch = queue.get()
461+
while not batch_queue.empty():
462+
request_batch = batch_queue.get()
452463

453464
# Send the batch to the API, assembling the body from the already serialized requests.
454465
response = self._http_client.call(
@@ -989,35 +1000,16 @@ async def batch_add_requests(
9891000
Returns:
9901001
Result containing lists of processed and unprocessed requests.
9911002
"""
992-
requests_as_dicts = [
993-
(
994-
request
995-
if isinstance(request, RequestDraft)
996-
else RequestDraft.model_validate(
997-
request,
998-
)
999-
).model_dump(
1000-
by_alias=True,
1001-
exclude_none=True,
1002-
)
1003-
for request in requests
1004-
]
1005-
1006-
# Serializing many requests is CPU-bound and would block the event loop, so offload it to a worker
1007-
# thread (the HTTP client offloads request body compression the same way).
1008-
serialized_requests = await asyncio.to_thread(
1009-
lambda: [_serialize_request(request) for request in requests_as_dicts]
1010-
)
1003+
# Validate and serialize the requests in a worker thread, as it is CPU-bound and would block the event loop.
1004+
serialized_requests = await asyncio.to_thread(_serialize_requests, requests)
10111005

1012-
asyncio_queue: asyncio.Queue[Iterable[bytes]] = asyncio.Queue()
1006+
# Build the query parameters shared by all the batch API calls.
10131007
request_params = self._build_params(clientKey=self.client_key, forefront=forefront)
10141008

10151009
# Compute the payload size limit to ensure it doesn't exceed the maximum allowed size.
10161010
payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math.ceil(_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT)
10171011

1018-
# Split the requests into batches, constrained by the max payload size and max requests per batch. Each
1019-
# request costs its serialized bytes plus a separator, and the brackets are reserved up front, so an
1020-
# assembled `[...]` body can never exceed the limit.
1012+
# Split the requests into batches by payload size (counting commas and brackets) and max requests per batch.
10211013
batches = constrained_batches(
10221014
serialized_requests,
10231015
max_size=payload_size_limit_bytes - len(b'[]'),
@@ -1026,24 +1018,27 @@ async def batch_add_requests(
10261018
strict=False,
10271019
)
10281020

1021+
# Create a queue with all the batches, from which the worker tasks will consume them.
1022+
batch_queue: asyncio.Queue[Iterable[bytes]] = asyncio.Queue()
1023+
10291024
for batch in batches:
1030-
await asyncio_queue.put(batch)
1025+
await batch_queue.put(batch)
10311026

10321027
# Use TaskGroup for structured concurrency — automatic cleanup and error propagation.
10331028
try:
10341029
async with asyncio.TaskGroup() as tg:
10351030
workers = [
10361031
tg.create_task(
10371032
self._batch_add_requests_worker(
1038-
queue=asyncio_queue, request_params=request_params, timeout=timeout
1033+
queue=batch_queue, request_params=request_params, timeout=timeout
10391034
),
10401035
name=f'batch_add_requests_worker_{i}',
10411036
)
10421037
for i in range(max_parallel)
10431038
]
10441039

10451040
# Wait for all batches to be processed, then cancel idle workers.
1046-
await asyncio_queue.join()
1041+
await batch_queue.join()
10471042
for worker in workers:
10481043
worker.cancel()
10491044
except ExceptionGroup as eg:

0 commit comments

Comments
 (0)