Skip to content

Commit 0df0c12

Browse files
committed
perf: use plain int byte arithmetic in the dataset chunking loop
1 parent e1da3b8 commit 0df0c12

2 files changed

Lines changed: 26 additions & 11 deletions

File tree

src/apify/storage_clients/_apify/_dataset_client.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,8 @@ async def iterate_items(
214214
def _serialize_chunk(cls, items: Sequence[Mapping[str, JsonSerializable]], offset: int) -> tuple[str, int]:
215215
"""Serialize items starting at `offset` into one JSON array staying within the payload size limit.
216216
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`.
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`.
220219
221220
Args:
222221
items: The items to serialize.
@@ -228,26 +227,27 @@ def _serialize_chunk(cls, items: Sequence[Mapping[str, JsonSerializable]], offse
228227
Raises:
229228
ValueError: If an item is not JSON serializable or on its own exceeds the size limit.
230229
"""
230+
limit = cls._EFFECTIVE_LIMIT_SIZE.bytes
231231
payloads: list[str] = []
232-
chunk_size = ByteSize(2) # Add 2 bytes for [] wrapper.
232+
chunk_size = 2 # Add 2 bytes for [] wrapper.
233233

234234
for index in range(offset, len(items)):
235235
try:
236236
payload = json.dumps(items[index], ensure_ascii=False, separators=(',', ':'), default=str)
237237
except Exception as exc:
238238
raise ValueError(f'Data item at index {index} is not serializable to JSON.') from exc
239239

240-
payload_size = ByteSize(len(payload.encode('utf-8')))
241-
if payload_size > cls._EFFECTIVE_LIMIT_SIZE:
240+
payload_size = len(payload.encode('utf-8'))
241+
if payload_size > limit:
242242
raise ValueError(
243243
f'Data item at index {index} is too large '
244-
f'(size: {payload_size}, limit: {cls._EFFECTIVE_LIMIT_SIZE})'
244+
f'(size: {ByteSize(payload_size)}, limit: {cls._EFFECTIVE_LIMIT_SIZE})'
245245
)
246246

247-
if payloads and chunk_size + payload_size > cls._EFFECTIVE_LIMIT_SIZE:
247+
if payloads and chunk_size + payload_size > limit:
248248
return f'[{",".join(payloads)}]', index
249249

250250
payloads.append(payload)
251-
chunk_size += payload_size + ByteSize(1) # Add 1 byte for ',' separator.
251+
chunk_size += payload_size + 1 # Add 1 byte for ',' separator.
252252

253253
return f'[{",".join(payloads)}]', len(items)

tests/unit/storage_clients/test_apify_dataset_client.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,29 @@ async def test_push_data_sends_compact_json() -> None:
4848

4949
async def test_push_data_serializes_in_a_single_thread_hop_per_chunk(monkeypatch: pytest.MonkeyPatch) -> None:
5050
"""Serialization is offloaded once per pushed chunk rather than once per item."""
51+
monkeypatch.setattr(ApifyDatasetClient, '_EFFECTIVE_LIMIT_SIZE', ByteSize(200))
5152
to_thread = Mock(wraps=asyncio.to_thread)
5253
monkeypatch.setattr(asyncio, 'to_thread', to_thread)
5354
client, api_client = _make_dataset_client()
5455

5556
await client.push_data([{'id': i} for i in range(500)])
5657

57-
api_client.push_items.assert_awaited_once()
58-
assert to_thread.call_count == 1
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]
5974

6075

6176
async def test_push_data_splits_items_into_chunks_within_the_size_limit(monkeypatch: pytest.MonkeyPatch) -> None:

0 commit comments

Comments
 (0)