Skip to content

Commit e1da3b8

Browse files
committed
perf: serialize dataset push payloads once per chunk
1 parent a13a293 commit e1da3b8

2 files changed

Lines changed: 91 additions & 50 deletions

File tree

src/apify/storage_clients/_apify/_dataset_client.py

Lines changed: 32 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import json
45
from logging import getLogger
56
from typing import TYPE_CHECKING
67

78
from typing_extensions import override
89

910
from crawlee._utils.byte_size import ByteSize
10-
from crawlee._utils.file import json_dumps
1111
from crawlee.storage_clients._base import DatasetClient
1212
from crawlee.storage_clients.models import DatasetItemsListPage, DatasetMetadata
1313

@@ -138,18 +138,16 @@ async def drop(self) -> None:
138138

139139
@override
140140
async def push_data(self, data: Sequence[Mapping[str, JsonSerializable]] | Mapping[str, JsonSerializable]) -> None:
141-
async def payloads_generator(items: Sequence[Mapping[str, JsonSerializable]]) -> AsyncIterator[str]:
142-
for index, item in enumerate(items):
143-
yield await self._check_and_serialize(item, index)
144-
145141
async with self._charge_lock(), self._lock:
146142
items = data if self._is_sequence_of_items(data) else [data]
147143
if not items:
148144
return
149145
limit = self._compute_limit_for_push(len(items))
150146
items = items[:limit]
151147

152-
async for chunk in self._chunk_by_size(payloads_generator(items)):
148+
offset = 0
149+
while offset < len(items):
150+
chunk, offset = await asyncio.to_thread(self._serialize_chunk, items, offset)
153151
await self._api_client.push_items(items=chunk)
154152

155153
await self._charge_for_items(count_items=limit)
@@ -213,58 +211,43 @@ async def iterate_items(
213211
yield item
214212

215213
@classmethod
216-
async def _check_and_serialize(cls, item: Mapping[str, JsonSerializable], index: int | None = None) -> str:
217-
"""Serialize a given item to JSON, checks its serializability and size against a limit.
214+
def _serialize_chunk(cls, items: Sequence[Mapping[str, JsonSerializable]], offset: int) -> tuple[str, int]:
215+
"""Serialize items starting at `offset` into one JSON array staying within the payload size limit.
216+
217+
The array holds as many consecutive items as fit below `_EFFECTIVE_LIMIT_SIZE`, always at least one.
218+
The result is compact JSON - it goes on the wire, so indentation and separator padding would be
219+
pure overhead. This is CPU-bound and blocking; call it via `asyncio.to_thread`.
218220
219221
Args:
220-
item: The item to serialize.
221-
index: Index of the item, used for error context.
222+
items: The items to serialize.
223+
offset: Index of the first item to serialize.
222224
223225
Returns:
224-
Serialized JSON string.
226+
The JSON array string and the index of the first item that did not fit into it.
225227
226228
Raises:
227-
ValueError: If item is not JSON serializable or exceeds size limit.
229+
ValueError: If an item is not JSON serializable or on its own exceeds the size limit.
228230
"""
229-
s = ' ' if index is None else f' at index {index} '
230-
231-
try:
232-
payload = await json_dumps(item)
233-
except Exception as exc:
234-
raise ValueError(f'Data item{s}is not serializable to JSON.') from exc
235-
236-
payload_size = ByteSize(len(payload.encode('utf-8')))
237-
if payload_size > cls._EFFECTIVE_LIMIT_SIZE:
238-
raise ValueError(f'Data item{s}is too large (size: {payload_size}, limit: {cls._EFFECTIVE_LIMIT_SIZE})')
239-
240-
return payload
241-
242-
async def _chunk_by_size(self, items: AsyncIterator[str]) -> AsyncIterator[str]:
243-
"""Yield chunks of JSON arrays composed of input strings, respecting a size limit.
231+
payloads: list[str] = []
232+
chunk_size = ByteSize(2) # Add 2 bytes for [] wrapper.
244233

245-
Groups an iterable of JSON string payloads into larger JSON arrays, ensuring the total size
246-
of each array does not exceed `EFFECTIVE_LIMIT_SIZE`. Each output is a JSON array string that
247-
contains as many payloads as possible without breaching the size threshold, maintaining the
248-
order of the original payloads. Assumes individual items are below the size limit.
234+
for index in range(offset, len(items)):
235+
try:
236+
payload = json.dumps(items[index], ensure_ascii=False, separators=(',', ':'), default=str)
237+
except Exception as exc:
238+
raise ValueError(f'Data item at index {index} is not serializable to JSON.') from exc
249239

250-
Args:
251-
items: Iterable of JSON string payloads.
252-
253-
Yields:
254-
Strings representing JSON arrays of payloads, each staying within the size limit.
255-
"""
256-
last_chunk_size = ByteSize(2) # Add 2 bytes for [] wrapper.
257-
current_chunk = []
258-
259-
async for payload in items:
260240
payload_size = ByteSize(len(payload.encode('utf-8')))
241+
if payload_size > cls._EFFECTIVE_LIMIT_SIZE:
242+
raise ValueError(
243+
f'Data item at index {index} is too large '
244+
f'(size: {payload_size}, limit: {cls._EFFECTIVE_LIMIT_SIZE})'
245+
)
246+
247+
if payloads and chunk_size + payload_size > cls._EFFECTIVE_LIMIT_SIZE:
248+
return f'[{",".join(payloads)}]', index
261249

262-
if last_chunk_size + payload_size <= self._EFFECTIVE_LIMIT_SIZE:
263-
current_chunk.append(payload)
264-
last_chunk_size += payload_size + ByteSize(1) # Add 1 byte for ',' separator.
265-
else:
266-
yield f'[{",".join(current_chunk)}]'
267-
current_chunk = [payload]
268-
last_chunk_size = payload_size + ByteSize(2) # Add 2 bytes for [] wrapper.
250+
payloads.append(payload)
251+
chunk_size += payload_size + ByteSize(1) # Add 1 byte for ',' separator.
269252

270-
yield f'[{",".join(current_chunk)}]'
253+
return f'[{",".join(payloads)}]', len(items)

tests/unit/storage_clients/test_apify_dataset_client.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
from __future__ import annotations
22

33
import asyncio
4-
from unittest.mock import AsyncMock
4+
import json
5+
from unittest.mock import AsyncMock, Mock
56

67
import pytest
78

9+
from crawlee._utils.byte_size import ByteSize
10+
811
from apify.storage_clients._apify._dataset_client import ApifyDatasetClient
912

1013

@@ -31,3 +34,58 @@ async def test_drop_calls_api_delete() -> None:
3134
client, api_client = _make_dataset_client()
3235
await client.drop()
3336
api_client.delete.assert_awaited_once()
37+
38+
39+
async def test_push_data_sends_compact_json() -> None:
40+
"""Pushed payloads carry no indentation or separator padding."""
41+
client, api_client = _make_dataset_client()
42+
43+
await client.push_data([{'id': 1, 'name': 'first'}, {'id': 2, 'name': 'second'}])
44+
45+
chunk = api_client.push_items.await_args.kwargs['items']
46+
assert chunk == '[{"id":1,"name":"first"},{"id":2,"name":"second"}]'
47+
48+
49+
async def test_push_data_serializes_in_a_single_thread_hop_per_chunk(monkeypatch: pytest.MonkeyPatch) -> None:
50+
"""Serialization is offloaded once per pushed chunk rather than once per item."""
51+
to_thread = Mock(wraps=asyncio.to_thread)
52+
monkeypatch.setattr(asyncio, 'to_thread', to_thread)
53+
client, api_client = _make_dataset_client()
54+
55+
await client.push_data([{'id': i} for i in range(500)])
56+
57+
api_client.push_items.assert_awaited_once()
58+
assert to_thread.call_count == 1
59+
60+
61+
async def test_push_data_splits_items_into_chunks_within_the_size_limit(monkeypatch: pytest.MonkeyPatch) -> None:
62+
"""Items are pushed in several chunks, each staying within the payload size limit."""
63+
monkeypatch.setattr(ApifyDatasetClient, '_EFFECTIVE_LIMIT_SIZE', ByteSize(100))
64+
client, api_client = _make_dataset_client()
65+
items = [{'value': 'x' * 30} for _ in range(5)]
66+
67+
await client.push_data(items)
68+
69+
chunks = [call.kwargs['items'] for call in api_client.push_items.await_args_list]
70+
assert len(chunks) > 1
71+
assert all(len(chunk.encode('utf-8')) <= 100 for chunk in chunks)
72+
assert [item for chunk in chunks for item in json.loads(chunk)] == items
73+
74+
75+
async def test_push_data_rejects_an_oversized_item(monkeypatch: pytest.MonkeyPatch) -> None:
76+
"""An item exceeding the payload size limit raises with its index."""
77+
monkeypatch.setattr(ApifyDatasetClient, '_EFFECTIVE_LIMIT_SIZE', ByteSize(100))
78+
client, _ = _make_dataset_client()
79+
80+
with pytest.raises(ValueError, match='at index 1 is too large'):
81+
await client.push_data([{'id': 1}, {'value': 'x' * 200}])
82+
83+
84+
async def test_push_data_rejects_a_non_serializable_item() -> None:
85+
"""An item that cannot be serialized to JSON raises with its index."""
86+
client, _ = _make_dataset_client()
87+
circular: dict = {}
88+
circular['self'] = circular
89+
90+
with pytest.raises(ValueError, match='at index 0 is not serializable'):
91+
await client.push_data(circular)

0 commit comments

Comments
 (0)