Skip to content

Commit e698522

Browse files
authored
Honor max_bytes in topic reader receive_batch (#859)
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.
2 parents 3054b8f + df90735 commit e698522

6 files changed

Lines changed: 130 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
* Query session attach stream handles `NodeShutdown` and `SessionShutdown` session hints: on `NodeShutdown` the session's node connection is pessimized and the session is retired, on `SessionShutdown` the session is retired without touching the node
2+
* 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
23
* Support the timezone-carrying types `TzDate`, `TzDatetime` and `TzTimestamp` in query results and parameters (reading them previously raised `AttributeError`)
34
* Drop support for Python 3.8 and 3.9; the minimum supported version is now Python 3.10
45
* 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: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,23 +197,44 @@ 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 (even for a non-positive max_messages or
204+
a max_bytes smaller than a single message) so the caller always makes progress."""
201205
initial_length = len(self.messages)
202206

203-
if message_count >= initial_length:
204-
raise ValueError("Pop batch with size >= actual size is not supported.")
207+
message_count = initial_length
208+
if max_messages is not None:
209+
message_count = min(message_count, max_messages)
210+
if max_bytes is not None:
211+
# Only an aggregate byte size is known, so the per-message size (and
212+
# hence the cut) is approximate.
213+
one_message_size = self._bytes_size // initial_length
214+
message_count = min(message_count, max_bytes // max(1, one_message_size))
215+
message_count = max(1, message_count) # always take at least one message
216+
217+
return self._pop_batch_count(message_count)
218+
219+
def _pop_batch_count(self, message_count: int) -> PublicBatch:
220+
initial_length = len(self.messages)
205221

206-
one_message_size = self._bytes_size // initial_length
222+
if message_count >= initial_length:
223+
# Take the whole batch, keeping its exact byte size (the proportional
224+
# split below would drop the integer-division remainder).
225+
new_bytes_size = self._bytes_size
226+
else:
227+
new_bytes_size = (self._bytes_size // initial_length) * message_count
207228

208229
new_batch = PublicBatch(
209230
messages=self.messages[:message_count],
210231
_partition_session=self._partition_session,
211-
_bytes_size=one_message_size * message_count,
232+
_bytes_size=new_bytes_size,
212233
_codec=self._codec,
213234
)
214235

215236
self.messages = self.messages[message_count:]
216-
self._bytes_size = self._bytes_size - new_batch._bytes_size
237+
self._bytes_size = self._bytes_size - new_bytes_size
217238

218239
return new_batch
219240

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: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,80 @@ 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+
(0, None, 1), # non-positive max_messages -> still at least one
1381+
],
1382+
)
1383+
async def test_read_batch_max_bytes(
1384+
self,
1385+
stream_reader,
1386+
max_messages: typing.Optional[int],
1387+
max_bytes: typing.Optional[int],
1388+
actual_messages: int,
1389+
):
1390+
stream_reader._message_batches = OrderedDict(
1391+
{
1392+
0: PublicBatch(
1393+
messages=[stub_message(i) for i in range(1, 5)],
1394+
_partition_session=stub_partition_session(),
1395+
_bytes_size=40,
1396+
_codec=Codec.CODEC_RAW,
1397+
)
1398+
}
1399+
)
1400+
batch = stream_reader.receive_batch_nowait(max_messages=max_messages, max_bytes=max_bytes)
1401+
assert len(batch.messages) == actual_messages
1402+
1403+
async def test_receive_whole_batch_releases_exact_bytes(self, stream_reader, monkeypatch):
1404+
# Taking the whole batch must release its exact byte size back to the buffer,
1405+
# even when the size is not divisible by the message count (no rounding loss).
1406+
released = []
1407+
monkeypatch.setattr(stream_reader, "_buffer_release_bytes", released.append)
1408+
stream_reader._message_batches = OrderedDict(
1409+
{
1410+
0: PublicBatch(
1411+
messages=[stub_message(1), stub_message(2), stub_message(3)],
1412+
_partition_session=stub_partition_session(),
1413+
_bytes_size=100, # not divisible by 3
1414+
_codec=Codec.CODEC_RAW,
1415+
)
1416+
}
1417+
)
1418+
batch = stream_reader.receive_batch_nowait()
1419+
assert len(batch.messages) == 3
1420+
assert released == [100] # full size released, not (100 // 3) * 3 == 99
1421+
assert stream_reader._message_batches == OrderedDict() # nothing left buffered
1422+
1423+
async def test_receive_partial_batch_releases_only_cut_bytes(self, stream_reader, monkeypatch):
1424+
# A partial read releases only the taken bytes; the remainder stays buffered.
1425+
released = []
1426+
monkeypatch.setattr(stream_reader, "_buffer_release_bytes", released.append)
1427+
stream_reader._message_batches = OrderedDict(
1428+
{
1429+
0: PublicBatch(
1430+
messages=[stub_message(1), stub_message(2), stub_message(3)],
1431+
_partition_session=stub_partition_session(),
1432+
_bytes_size=90, # 30 per message
1433+
_codec=Codec.CODEC_RAW,
1434+
)
1435+
}
1436+
)
1437+
batch = stream_reader.receive_batch_nowait(max_messages=1)
1438+
assert len(batch.messages) == 1
1439+
assert released == [30] # only the cut is released
1440+
remainder = stream_reader._message_batches[0]
1441+
assert len(remainder.messages) == 2
1442+
assert remainder._bytes_size == 60 # remainder stays buffered
1443+
13701444
async def test_receive_batch_nowait(self, stream, stream_reader, partition_session):
13711445
assert stream_reader.receive_batch_nowait() is None
13721446

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)