Skip to content

Commit eb6475d

Browse files
authored
perf: serialize dataset push payloads once per chunk (#1081)
Pushing to a dataset serialized every item in its own `asyncio.to_thread` hop (through Crawlee's `json_dumps`), so a push paid one thread round-trip per item. That helper also pretty-prints with `indent=2`, and each payload was UTF-8 encoded twice — once for the per-item size check, once again while chunking. `_check_and_serialize` and `_chunk_by_size` are replaced by a single blocking `_serialize_chunk(items, offset)` that fills one size-bounded JSON array and returns the next offset. `push_data` drives it with one `asyncio.to_thread` per chunk and pushes each chunk before serializing the next, so payloads are still streamed rather than all materialized up front. Serialization is now local and compact (`json.dumps(..., separators=(',', ':'))`) rather than Crawlee's `json_dumps`, whose `indent=2` is deliberate for the on-disk files people actually read. Nothing reads the wire format. Dropping the indent also lets CPython take the C encoder fast path, which it only does when `indent is None`. Measured on 20k realistic items against a mocked API client: | | time | payload | | ------ | ------ | -------- | | before | 1.081s | 10.78 MB | | after | 0.075s | 9.34 MB | The payload saving is shape-dependent — larger for small or deeply nested items — and compounds with the brotli work in #1052. Two spurious API calls disappear as a side effect: the old chunker emitted an empty `[]` chunk whenever the first item of a chunk landed within 2 bytes of the limit, and it pushed `[]` rather than nothing when PPE charging drove the push limit down to 0. Stored items are unaffected. The only observable difference is that raw request bodies are no longer pretty-printed. *✍️ Drafted by Claude Code*
1 parent a13a293 commit eb6475d

2 files changed

Lines changed: 111 additions & 55 deletions

File tree

src/apify/storage_clients/_apify/_dataset_client.py

Lines changed: 37 additions & 54 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 within `_EFFECTIVE_LIMIT_SIZE`, always at least one. Output
218+
is compact JSON - it goes straight on the wire. This is CPU-bound and blocking; call it via `asyncio.to_thread`.
218219
219220
Args:
220-
item: The item to serialize.
221-
index: Index of the item, used for error context.
221+
items: The items to serialize.
222+
offset: Index of the first item to serialize.
222223
223224
Returns:
224-
Serialized JSON string.
225+
The JSON array string and the index of the first item that did not fit into it.
225226
226227
Raises:
227-
ValueError: If item is not JSON serializable or exceeds size limit.
228-
"""
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.
244-
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.
249-
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.
228+
ValueError: If an item is not JSON serializable or on its own exceeds the size limit.
255229
"""
256-
last_chunk_size = ByteSize(2) # Add 2 bytes for [] wrapper.
257-
current_chunk = []
258-
259-
async for payload in items:
260-
payload_size = ByteSize(len(payload.encode('utf-8')))
261-
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.
269-
270-
yield f'[{",".join(current_chunk)}]'
230+
limit = cls._EFFECTIVE_LIMIT_SIZE.bytes
231+
payloads: list[str] = []
232+
chunk_size = 2 # Add 2 bytes for [] wrapper.
233+
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
239+
240+
payload_size = len(payload.encode('utf-8'))
241+
if payload_size > limit:
242+
raise ValueError(
243+
f'Data item at index {index} is too large '
244+
f'(size: {ByteSize(payload_size)}, limit: {cls._EFFECTIVE_LIMIT_SIZE})'
245+
)
246+
247+
if payloads and chunk_size + payload_size > limit:
248+
return f'[{",".join(payloads)}]', index
249+
250+
payloads.append(payload)
251+
chunk_size += payload_size + 1 # Add 1 byte for ',' separator.
252+
253+
return f'[{",".join(payloads)}]', len(items)

tests/unit/storage_clients/test_apify_dataset_client.py

Lines changed: 74 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,73 @@ 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+
monkeypatch.setattr(ApifyDatasetClient, '_EFFECTIVE_LIMIT_SIZE', ByteSize(200))
52+
to_thread = Mock(wraps=asyncio.to_thread)
53+
monkeypatch.setattr(asyncio, 'to_thread', to_thread)
54+
client, api_client = _make_dataset_client()
55+
56+
await client.push_data([{'id': i} for i in range(500)])
57+
58+
assert api_client.push_items.await_count > 1
59+
assert to_thread.call_count == api_client.push_items.await_count
60+
61+
62+
async def test_push_data_makes_progress_when_an_item_fills_a_whole_chunk(monkeypatch: pytest.MonkeyPatch) -> None:
63+
"""An item that fits the limit only without the array wrapper still yields one chunk per item."""
64+
items = [{'value': 'x' * 30} for _ in range(3)]
65+
payloads = [json.dumps(item, ensure_ascii=False, separators=(',', ':')) for item in items]
66+
monkeypatch.setattr(ApifyDatasetClient, '_EFFECTIVE_LIMIT_SIZE', ByteSize(len(payloads[0].encode('utf-8'))))
67+
client, api_client = _make_dataset_client()
68+
69+
async with asyncio.timeout(5):
70+
await client.push_data(items)
71+
72+
chunks = [call.kwargs['items'] for call in api_client.push_items.await_args_list]
73+
assert chunks == [f'[{payload}]' for payload in payloads]
74+
75+
76+
async def test_push_data_splits_items_into_chunks_within_the_size_limit(monkeypatch: pytest.MonkeyPatch) -> None:
77+
"""Items are pushed in several chunks, each staying within the payload size limit."""
78+
monkeypatch.setattr(ApifyDatasetClient, '_EFFECTIVE_LIMIT_SIZE', ByteSize(100))
79+
client, api_client = _make_dataset_client()
80+
items = [{'value': 'x' * 30} for _ in range(5)]
81+
82+
await client.push_data(items)
83+
84+
chunks = [call.kwargs['items'] for call in api_client.push_items.await_args_list]
85+
assert len(chunks) > 1
86+
assert all(len(chunk.encode('utf-8')) <= 100 for chunk in chunks)
87+
assert [item for chunk in chunks for item in json.loads(chunk)] == items
88+
89+
90+
async def test_push_data_rejects_an_oversized_item(monkeypatch: pytest.MonkeyPatch) -> None:
91+
"""An item exceeding the payload size limit raises with its index."""
92+
monkeypatch.setattr(ApifyDatasetClient, '_EFFECTIVE_LIMIT_SIZE', ByteSize(100))
93+
client, _ = _make_dataset_client()
94+
95+
with pytest.raises(ValueError, match='at index 1 is too large'):
96+
await client.push_data([{'id': 1}, {'value': 'x' * 200}])
97+
98+
99+
async def test_push_data_rejects_a_non_serializable_item() -> None:
100+
"""An item that cannot be serialized to JSON raises with its index."""
101+
client, _ = _make_dataset_client()
102+
circular: dict = {}
103+
circular['self'] = circular
104+
105+
with pytest.raises(ValueError, match='at index 0 is not serializable'):
106+
await client.push_data(circular)

0 commit comments

Comments
 (0)