Skip to content

Commit 9ae3069

Browse files
committed
Reduce memory usage of query service result sets
Reading large result sets via the query service kept far more memory alive than the decoded data itself required. The converted result set referenced the source protobuf message through `_ResultSet.columns` and through `_Row._columns` on every row. With the upb protobuf backend, any reference into a message pins its entire arena, so the raw `message.rows` payload stayed alive for the whole lifetime of the result set — the decoded Python objects and the full source protobuf were held simultaneously. Detach the result set from the source arena: copy the schema into a standalone message and store a shared tuple of column names on rows instead of the proto columns, so the source arena is released right after conversion. Also copy the Arrow `data` payload out of the arena. Additionally give the row/struct dict subclasses `__slots__` to drop the redundant per-row instance `__dict__`, and make `_DotDict.__getattr__` raise `AttributeError` instead of leaking `KeyError` (which had broken `copy.copy`/`copy.deepcopy` of rows). Peak RSS for a streaming read of 20x20k rows (results kept) drops from ~953 MB to ~298 MB.
1 parent f2c742d commit 9ae3069

2 files changed

Lines changed: 107 additions & 11 deletions

File tree

ydb/convert.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,16 @@ def _initialize():
2424

2525

2626
class _DotDict(dict):
27+
__slots__ = ()
28+
2729
def __init__(self, *args, **kwargs):
2830
super(_DotDict, self).__init__(*args, **kwargs)
2931

3032
def __getattr__(self, item):
31-
return self[item]
33+
try:
34+
return self[item]
35+
except KeyError:
36+
raise AttributeError(item)
3237

3338

3439
def _is_decimal_signed(hi_value):
@@ -83,7 +88,7 @@ def _pb_to_dict(type_pb, value_pb, table_client_settings):
8388

8489

8590
class _Struct(_DotDict):
86-
pass
91+
__slots__ = ()
8792

8893

8994
def _pb_to_struct(type_pb, value_pb, table_client_settings):
@@ -351,6 +356,16 @@ def _unwrap_optionality(column):
351356
return _to_native_map.get(current_type), c_type
352357

353358

359+
def _detach_columns(columns):
360+
# The columns container references the source protobuf message, which keeps
361+
# the whole arena (including already-parsed message.rows) alive for as long
362+
# as the result set is held. Copy the schema into a standalone message so the
363+
# source arena can be released right after conversion.
364+
holder = _apis.ydb_value.ResultSet()
365+
holder.columns.extend(columns)
366+
return holder.columns
367+
368+
354369
class _ResultSet(object):
355370
__slots__ = ("columns", "rows", "truncated", "snapshot", "index", "format", "arrow_format_meta", "data")
356371

@@ -375,20 +390,25 @@ def from_message(cls, message, table_client_settings=None, snapshot=None, index=
375390
for column in message.columns:
376391
column_parsers.append(_unwrap_optionality(column))
377392

393+
# Names are the only per-row metadata we need. Storing this shared tuple
394+
# (instead of the proto columns) keeps rows detached from the source
395+
# protobuf arena, so it can be freed once conversion is done.
396+
column_names = tuple(column.name for column in message.columns)
397+
378398
for row_proto in message.rows:
379-
row = _Row(message.columns)
380-
for column, value, column_info in zip(message.columns, row_proto.items, column_parsers):
399+
row = _Row(column_names)
400+
for name, value, column_info in zip(column_names, row_proto.items, column_parsers):
381401
v_type = value.WhichOneof("value")
382402
if v_type == "null_flag_value":
383-
row[column.name] = None
403+
row[name] = None
384404
continue
385405

386406
while v_type == "nested_value":
387407
value = value.nested_value
388408
v_type = value.WhichOneof("value")
389409

390410
column_parser, unwrapped_type = column_info
391-
row[column.name] = column_parser(unwrapped_type, value, table_client_settings)
411+
row[name] = column_parser(unwrapped_type, value, table_client_settings)
392412
rows.append(row)
393413

394414
from ydb.query import QueryResultSetFormat, ArrowFormatMeta
@@ -399,9 +419,11 @@ def from_message(cls, message, table_client_settings=None, snapshot=None, index=
399419
if message.HasField("arrow_format_meta"):
400420
arrow_meta = ArrowFormatMeta.from_proto(message.arrow_format_meta)
401421

402-
data = message.data if message.data else None
422+
data = bytes(message.data) if message.data else None
403423

404-
return cls(message.columns, rows, message.truncated, snapshot, index, result_format, arrow_meta, data)
424+
return cls(
425+
_detach_columns(message.columns), rows, message.truncated, snapshot, index, result_format, arrow_meta, data
426+
)
405427

406428
@classmethod
407429
def lazy_from_message(cls, message, table_client_settings=None, snapshot=None):
@@ -422,15 +444,17 @@ def lazy_from_message(cls, message, table_client_settings=None, snapshot=None):
422444

423445

424446
class _Row(_DotDict):
447+
__slots__ = ("_columns",)
448+
425449
def __init__(self, columns):
426450
super(_Row, self).__init__()
427451
self._columns = columns
428452

429453
def __getitem__(self, key):
430454
if isinstance(key, int):
431-
return self[self._columns[key].name]
455+
return self[self._columns[key]]
432456
elif isinstance(key, slice):
433-
return tuple(map(lambda x: self[x.name], self._columns[key]))
457+
return tuple(self[name] for name in self._columns[key])
434458
else:
435459
return super(_Row, self).__getitem__(key)
436460

@@ -455,6 +479,8 @@ def get(self):
455479

456480

457481
class _LazyRow(_DotDict):
482+
__slots__ = ("_columns", "_table_client_settings")
483+
458484
def __init__(self, columns, proto_row, table_client_settings, parsers):
459485
super(_LazyRow, self).__init__()
460486
self._columns = columns

ydb/table_test.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import copy
2+
13
from unittest import mock
2-
from . import issues
4+
from . import issues, convert, types, _apis
35

46
from .retries import (
57
retry_operation_impl,
@@ -9,6 +11,74 @@
911
)
1012

1113

14+
def _build_int_result_set(n_rows, n_cols):
15+
result_set = _apis.ydb_value.ResultSet()
16+
for col_idx in range(n_cols):
17+
column = result_set.columns.add()
18+
column.name = "column_%d" % col_idx
19+
column.type.type_id = types.PrimitiveType.Int64._idn_
20+
for row_idx in range(n_rows):
21+
row = result_set.rows.add()
22+
for col_idx in range(n_cols):
23+
row.items.add().int64_value = row_idx * 1000 + col_idx
24+
return result_set
25+
26+
27+
def test_result_set_row_access():
28+
message = _build_int_result_set(n_rows=1, n_cols=3)
29+
row = convert.ResultSet.from_message(message).rows[0]
30+
31+
assert row["column_1"] == 1
32+
assert row.column_1 == 1
33+
assert row[1] == 1
34+
assert row[0:2] == (0, 1)
35+
assert dict(row) == {"column_0": 0, "column_1": 1, "column_2": 2}
36+
37+
38+
def test_result_set_row_has_no_instance_dict():
39+
# Rows must not carry a per-instance __dict__: it is pure memory overhead
40+
# multiplied by every row in a large result set.
41+
message = _build_int_result_set(n_rows=1, n_cols=3)
42+
row = convert.ResultSet.from_message(message).rows[0]
43+
assert not hasattr(row, "__dict__")
44+
45+
46+
def test_result_set_row_missing_attribute_raises_attribute_error():
47+
message = _build_int_result_set(n_rows=1, n_cols=1)
48+
row = convert.ResultSet.from_message(message).rows[0]
49+
50+
assert not hasattr(row, "definitely_missing")
51+
try:
52+
row.definitely_missing
53+
except AttributeError:
54+
pass
55+
else:
56+
raise AssertionError("expected AttributeError for missing attribute")
57+
58+
59+
def test_result_set_row_is_copyable():
60+
message = _build_int_result_set(n_rows=1, n_cols=3)
61+
row = convert.ResultSet.from_message(message).rows[0]
62+
63+
assert dict(copy.copy(row)) == dict(row)
64+
assert dict(copy.deepcopy(row)) == dict(row)
65+
66+
67+
def test_result_set_detached_from_source_message():
68+
# The converted result set must not hold a reference into the source
69+
# protobuf: otherwise the whole arena (raw rows included) stays alive for
70+
# the lifetime of the result, doubling memory usage on large reads.
71+
message = _build_int_result_set(n_rows=2, n_cols=2)
72+
result = convert.ResultSet.from_message(message)
73+
74+
message.Clear() # emulate the source proto being dropped/reused by the stream
75+
76+
assert [column.name for column in result.columns] == ["column_0", "column_1"]
77+
assert result.rows[0]["column_1"] == 1
78+
assert result.rows[1][0] == 1000
79+
assert result.rows[1][0:2] == (1000, 1001)
80+
81+
1282
def test_retry_operation_impl(monkeypatch):
1383
monkeypatch.setattr("random.random", lambda: 0.5)
1484
monkeypatch.setattr(

0 commit comments

Comments
 (0)