Skip to content

Commit 8a0e39d

Browse files
committed
Honor max_bytes in topic reader receive_batch
The parameter was accepted by the sync reader but silently ignored, and was missing from the async reader. Cap the returned batch by both max_messages and max_bytes (approximate, from the batch's aggregate size), always returning at least one message. Threaded through the sync and async readers.
1 parent dc20074 commit 8a0e39d

6 files changed

Lines changed: 127 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
* Honor the `max_bytes` parameter in topic reader `receive_batch`/`receive_batch_with_tx` (previously accepted but silently ignored); add `max_bytes` to the async reader as well
12
* Support the timezone-carrying types `TzDate`, `TzDatetime` and `TzTimestamp` in query results and parameters (reading them previously raised `AttributeError`)
23
* Drop support for Python 3.8 and 3.9; the minimum supported version is now Python 3.10
34
* Accept native `datetime.datetime` values for `Datetime` and `Datetime64` query parameters (previously only integer seconds since the epoch were accepted)

docs/topic.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,8 +456,8 @@ Receiving Messages
456456
process(message)
457457
reader.commit(batch) # commit the whole batch at once
458458
459-
# With size limits
460-
batch = reader.receive_batch(max_messages=100)
459+
# With size limits (either or both; at least one message is always returned)
460+
batch = reader.receive_batch(max_messages=100, max_bytes=1024 * 1024)
461461
462462
# Asynchronous
463463
batch = await reader.receive_batch()

ydb/_topic_reader/datatypes.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,34 @@ def _pop(self) -> Tuple[PublicMessage, bool]:
197197
msgs_left = True if len(self.messages) > 1 else False
198198
return self.messages.pop(0), msgs_left
199199

200-
def _pop_batch(self, message_count: int) -> PublicBatch:
200+
def _pop_batch(self, max_messages: Optional[int] = None, max_bytes: Optional[int] = None) -> PublicBatch:
201+
"""Split off and return a prefix of the batch, capped by max_messages and/or
202+
max_bytes. The remainder stays in self (empty if the whole batch was taken).
203+
At least one message is always taken so a caller whose max_bytes is smaller
204+
than a single message still makes progress."""
201205
initial_length = len(self.messages)
206+
one_message_size = self._bytes_size // initial_length
202207

203-
if message_count >= initial_length:
204-
raise ValueError("Pop batch with size >= actual size is not supported.")
208+
message_count = initial_length
209+
if max_messages is not None:
210+
message_count = min(message_count, max_messages)
211+
if max_bytes is not None:
212+
# Only an aggregate byte size is known, so the per-message size (and
213+
# hence the cut) is approximate.
214+
message_count = min(message_count, max(1, max_bytes // max(1, one_message_size)))
205215

206-
one_message_size = self._bytes_size // initial_length
216+
if message_count >= initial_length:
217+
# Take the whole batch, keeping its exact byte size (the proportional
218+
# split below would drop the integer-division remainder).
219+
new_batch = PublicBatch(
220+
messages=self.messages,
221+
_partition_session=self._partition_session,
222+
_bytes_size=self._bytes_size,
223+
_codec=self._codec,
224+
)
225+
self.messages = []
226+
self._bytes_size = 0
227+
return new_batch
207228

208229
new_batch = PublicBatch(
209230
messages=self.messages[:message_count],

ydb/_topic_reader/topic_reader_asyncio.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,35 +117,47 @@ async def wait_message(self):
117117
async def receive_batch(
118118
self,
119119
max_messages: typing.Union[int, None] = None,
120+
max_bytes: typing.Union[int, None] = None,
120121
) -> typing.Union[datatypes.PublicBatch, None]:
121122
"""
122123
Get one messages batch from reader.
123124
All messages in a batch from same partition.
124125
126+
The batch is capped by max_messages and/or max_bytes when set; at least
127+
one message is always returned. max_bytes uses the batch's server-reported
128+
size, so the cut is approximate.
129+
125130
use asyncio.wait_for for wait with timeout.
126131
"""
127-
logger.debug("receive_batch max_messages=%s", max_messages)
132+
logger.debug("receive_batch max_messages=%s max_bytes=%s", max_messages, max_bytes)
128133
await self._reconnector.wait_message()
129134
return self._reconnector.receive_batch_nowait(
130135
max_messages=max_messages,
136+
max_bytes=max_bytes,
131137
)
132138

133139
async def receive_batch_with_tx(
134140
self,
135141
tx: "BaseQueryTxContext",
136142
max_messages: typing.Union[int, None] = None,
143+
max_bytes: typing.Union[int, None] = None,
137144
) -> typing.Union[datatypes.PublicBatch, None]:
138145
"""
139146
Get one messages batch with tx from reader.
140147
All messages in a batch from same partition.
141148
149+
The batch is capped by max_messages and/or max_bytes when set; at least
150+
one message is always returned. max_bytes uses the batch's server-reported
151+
size, so the cut is approximate.
152+
142153
use asyncio.wait_for for wait with timeout.
143154
"""
144-
logger.debug("receive_batch_with_tx tx=%s max_messages=%s", tx, max_messages)
155+
logger.debug("receive_batch_with_tx tx=%s max_messages=%s max_bytes=%s", tx, max_messages, max_bytes)
145156
await self._reconnector.wait_message()
146157
return self._reconnector.receive_batch_with_tx_nowait(
147158
tx=tx,
148159
max_messages=max_messages,
160+
max_bytes=max_bytes,
149161
)
150162

151163
async def receive_message(self) -> typing.Optional[datatypes.PublicMessage]:
@@ -293,18 +305,22 @@ async def wait_message(self):
293305
await self._state_changed.wait()
294306
self._state_changed.clear()
295307

296-
def receive_batch_nowait(self, max_messages: Optional[int] = None):
308+
def receive_batch_nowait(self, max_messages: Optional[int] = None, max_bytes: Optional[int] = None):
297309
if self._stream_reader is None:
298310
return None
299311
return self._stream_reader.receive_batch_nowait(
300312
max_messages=max_messages,
313+
max_bytes=max_bytes,
301314
)
302315

303-
def receive_batch_with_tx_nowait(self, tx: "BaseQueryTxContext", max_messages: Optional[int] = None):
316+
def receive_batch_with_tx_nowait(
317+
self, tx: "BaseQueryTxContext", max_messages: Optional[int] = None, max_bytes: Optional[int] = None
318+
):
304319
if self._stream_reader is None:
305320
return None
306321
batch = self._stream_reader.receive_batch_nowait(
307322
max_messages=max_messages,
323+
max_bytes=max_bytes,
308324
)
309325

310326
self._init_tx(tx)
@@ -651,7 +667,7 @@ def _return_batch_to_queue(self, part_sess_id: int, batch: datatypes.PublicBatch
651667
if part_sess_id in self._partition_sessions and self._partition_sessions[part_sess_id].ended:
652668
self._message_batches.move_to_end(part_sess_id, last=False)
653669

654-
def receive_batch_nowait(self, max_messages: Optional[int] = None):
670+
def receive_batch_nowait(self, max_messages: Optional[int] = None, max_bytes: Optional[int] = None):
655671
first_error = self._get_first_error()
656672
if first_error is not None:
657673
raise first_error
@@ -661,13 +677,10 @@ def receive_batch_nowait(self, max_messages: Optional[int] = None):
661677

662678
part_sess_id, batch = self._get_first_batch()
663679

664-
if max_messages is None or len(batch.messages) <= max_messages:
665-
self._buffer_release_bytes(batch._bytes_size)
666-
return batch
667-
668-
cutted_batch = batch._pop_batch(message_count=max_messages)
680+
cutted_batch = batch._pop_batch(max_messages=max_messages, max_bytes=max_bytes)
669681

670-
self._return_batch_to_queue(part_sess_id, batch)
682+
if not batch.empty():
683+
self._return_batch_to_queue(part_sess_id, batch)
671684

672685
self._buffer_release_bytes(cutted_batch._bytes_size)
673686

ydb/_topic_reader/topic_reader_asyncio_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,79 @@ async def test_read_batch_max_messages(
13671367
assert len(batch.messages) == actual_messages
13681368
assert stream_reader._message_batches == OrderedDict(batches_after)
13691369

1370+
@pytest.mark.parametrize(
1371+
"max_messages,max_bytes,actual_messages",
1372+
[
1373+
(None, None, 4), # no limits -> whole batch
1374+
(None, 100, 4), # max_bytes above total -> whole batch
1375+
(None, 25, 2), # batch is 40 bytes / 4 msgs = 10 each -> ~2 fit
1376+
(None, 10, 1), # exactly one message
1377+
(None, 5, 1), # below one message -> still return at least one
1378+
(3, 25, 2), # both limits -> smaller (bytes) wins
1379+
(1, 100, 1), # both limits -> smaller (messages) wins
1380+
],
1381+
)
1382+
async def test_read_batch_max_bytes(
1383+
self,
1384+
stream_reader,
1385+
max_messages: typing.Optional[int],
1386+
max_bytes: typing.Optional[int],
1387+
actual_messages: int,
1388+
):
1389+
stream_reader._message_batches = OrderedDict(
1390+
{
1391+
0: PublicBatch(
1392+
messages=[stub_message(i) for i in range(1, 5)],
1393+
_partition_session=stub_partition_session(),
1394+
_bytes_size=40,
1395+
_codec=Codec.CODEC_RAW,
1396+
)
1397+
}
1398+
)
1399+
batch = stream_reader.receive_batch_nowait(max_messages=max_messages, max_bytes=max_bytes)
1400+
assert len(batch.messages) == actual_messages
1401+
1402+
async def test_receive_whole_batch_releases_exact_bytes(self, stream_reader, monkeypatch):
1403+
# Taking the whole batch must release its exact byte size back to the buffer,
1404+
# even when the size is not divisible by the message count (no rounding loss).
1405+
released = []
1406+
monkeypatch.setattr(stream_reader, "_buffer_release_bytes", released.append)
1407+
stream_reader._message_batches = OrderedDict(
1408+
{
1409+
0: PublicBatch(
1410+
messages=[stub_message(1), stub_message(2), stub_message(3)],
1411+
_partition_session=stub_partition_session(),
1412+
_bytes_size=100, # not divisible by 3
1413+
_codec=Codec.CODEC_RAW,
1414+
)
1415+
}
1416+
)
1417+
batch = stream_reader.receive_batch_nowait()
1418+
assert len(batch.messages) == 3
1419+
assert released == [100] # full size released, not (100 // 3) * 3 == 99
1420+
assert stream_reader._message_batches == OrderedDict() # nothing left buffered
1421+
1422+
async def test_receive_partial_batch_releases_only_cut_bytes(self, stream_reader, monkeypatch):
1423+
# A partial read releases only the taken bytes; the remainder stays buffered.
1424+
released = []
1425+
monkeypatch.setattr(stream_reader, "_buffer_release_bytes", released.append)
1426+
stream_reader._message_batches = OrderedDict(
1427+
{
1428+
0: PublicBatch(
1429+
messages=[stub_message(1), stub_message(2), stub_message(3)],
1430+
_partition_session=stub_partition_session(),
1431+
_bytes_size=90, # 30 per message
1432+
_codec=Codec.CODEC_RAW,
1433+
)
1434+
}
1435+
)
1436+
batch = stream_reader.receive_batch_nowait(max_messages=1)
1437+
assert len(batch.messages) == 1
1438+
assert released == [30] # only the cut is released
1439+
remainder = stream_reader._message_batches[0]
1440+
assert len(remainder.messages) == 2
1441+
assert remainder._bytes_size == 60 # remainder stays buffered
1442+
13701443
async def test_receive_batch_nowait(self, stream, stream_reader, partition_session):
13711444
assert stream_reader.receive_batch_nowait() is None
13721445

ydb/_topic_reader/topic_reader_sync.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def receive_batch(
120120
return self._caller.safe_call_with_result(
121121
self._async_reader.receive_batch(
122122
max_messages=max_messages,
123+
max_bytes=max_bytes,
123124
),
124125
timeout,
125126
)
@@ -145,6 +146,7 @@ def receive_batch_with_tx(
145146
self._async_reader.receive_batch_with_tx(
146147
tx=tx,
147148
max_messages=max_messages,
149+
max_bytes=max_bytes,
148150
),
149151
timeout,
150152
)

0 commit comments

Comments
 (0)