Skip to content

Commit a2ca99e

Browse files
committed
Merge streamed result set parts in execute_with_retries
Stream parts that share a result_set_index are concatenated back into a single ResultSet, so execute_with_retries returns one result set per SELECT regardless of its size instead of one per stream part.
1 parent 3bcd540 commit a2ca99e

7 files changed

Lines changed: 101 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
* Fixed `execute_with_retries` returning a large result set split across several `ResultSet` objects: stream parts that share a `result_set_index` are now concatenated back into a single result set, so a query is returned as one result set per SELECT regardless of its size
12
* Introduce `ydb.observability` — vendor-neutral tracing entrypoint with a `TracingProvider` interface; `enable_tracing(provider)` accepts any implementation (custom or OpenTelemetry) and replaces the previously installed one. The SDK core no longer imports `opentelemetry`, so tracing can be enabled without the OpenTelemetry packages by supplying a custom provider
23
* When tracing is enabled the SDK appends a `ydb-sdk-tracing/0.1.0` token to the `x-ydb-sdk-build-info` header, so the server can distinguish requests from tracing-enabled clients
34
* Add client-side metrics through the same vendor-neutral `ydb.observability` layer: `enable_metrics(provider)` / `disable_metrics()`, with `ydb.opentelemetry.enable_metrics(meter_provider=None)` as the OpenTelemetry convenience. Instruments cover client operation duration/failures, retry duration/attempts, and query session pool state. `QuerySessionPool` gains an optional `name` argument for the pool metric label. When metrics are enabled the SDK appends a `ydb-sdk-metrics/0.1.0` token to the `x-ydb-sdk-build-info` header

tests/aio/query/test_query_session_pool.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ async def test_oneshot_query_normal(self, pool: QuerySessionPool):
2323
res = await pool.execute_with_retries("select 1;")
2424
assert len(res) == 1
2525

26+
@pytest.mark.asyncio
27+
async def test_oneshot_query_merges_large_result_set_parts(self, pool: QuerySessionPool):
28+
row_count = 100_000
29+
query = f"SELECT * FROM AS_TABLE(ListMap(ListFromRange(0ul, {row_count}ul), ($x) -> (<|id: $x|>)))"
30+
res = await pool.execute_with_retries(query)
31+
assert len(res) == 1
32+
assert res[0].index == 0
33+
assert len(res[0].rows) == row_count
34+
2635
@pytest.mark.asyncio
2736
async def test_oneshot_ddl_query(self, pool: QuerySessionPool):
2837
await pool.execute_with_retries("drop table if exists Queen;")

tests/query/test_query_session_pool.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ def test_oneshot_query_result_set_index(self, pool: QuerySessionPool):
2828
indexes = [result_set.index for result_set in res]
2929
assert indexes == [0, 1, 2]
3030

31+
def test_oneshot_query_merges_large_result_set_parts(self, pool: QuerySessionPool):
32+
row_count = 100_000
33+
query = f"SELECT * FROM AS_TABLE(ListMap(ListFromRange(0ul, {row_count}ul), ($x) -> (<|id: $x|>)))"
34+
res = pool.execute_with_retries(query)
35+
assert len(res) == 1
36+
assert res[0].index == 0
37+
assert len(res[0].rows) == row_count
38+
3139
def test_oneshot_ddl_query(self, pool: QuerySessionPool):
3240
pool.execute_with_retries("create table Queen(key UInt64, PRIMARY KEY (key));")
3341
pool.execute_with_retries("drop table Queen;")

ydb/aio/query/pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ async def execute_with_retries(
237237
async def wrapped_callee():
238238
async with self.checkout(timeout=retry_settings.max_session_acquire_timeout) as session:
239239
it = await session.execute(query, parameters, *args, **kwargs)
240-
return [result_set async for result_set in it]
240+
return convert.aggregate_result_sets_by_index([result_set async for result_set in it])
241241

242242
return await retry_operation_async(wrapped_callee, retry_settings)
243243

ydb/convert.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,36 @@ def lazy_from_message(cls, message, table_client_settings=None, snapshot=None):
460460
ResultSet = _ResultSet
461461

462462

463+
def aggregate_result_sets_by_index(result_sets):
464+
"""Glue together stream parts that belong to the same result set.
465+
466+
The query service streams one logical result set as several response parts
467+
that share a single ``result_set_index``. This concatenates the rows (and
468+
arrow ``data``) of those parts back into a single result set, keeping the
469+
schema from the first part that carries it.
470+
"""
471+
merged = []
472+
by_index = {}
473+
for result_set in result_sets:
474+
index = result_set.index
475+
target = by_index.get(index) if index is not None else None
476+
if target is None:
477+
merged.append(result_set)
478+
if index is not None:
479+
by_index[index] = result_set
480+
continue
481+
482+
target.rows.extend(result_set.rows)
483+
if result_set.truncated:
484+
target.truncated = True
485+
if not target.columns and result_set.columns:
486+
target.columns = result_set.columns
487+
if result_set.data is not None:
488+
target.data = result_set.data if target.data is None else target.data + result_set.data
489+
490+
return merged
491+
492+
463493
class _Row(_DotDict):
464494
__slots__ = ("_columns",)
465495

ydb/query/pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ def execute_with_retries(
264264
def wrapped_callee():
265265
with self.checkout(timeout=retry_settings.max_session_acquire_timeout) as session:
266266
it = session.execute(query, parameters, *args, **kwargs)
267-
return [result_set for result_set in it]
267+
return convert.aggregate_result_sets_by_index([result_set for result_set in it])
268268

269269
return retry_operation_sync(wrapped_callee, retry_settings)
270270

ydb/query/pool_test.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import MagicMock
77

88
from ydb import issues
9+
from ydb.convert import _ResultSet, aggregate_result_sets_by_index
910
from ydb.query.pool import QuerySessionPool
1011
from ydb.query.session import QuerySession
1112

@@ -57,6 +58,56 @@ def release_after_delay():
5758
t.join()
5859

5960

61+
def _rs(index, rows, columns=None, truncated=False, data=None):
62+
return _ResultSet(
63+
columns=["id"] if columns is None else columns,
64+
rows=list(rows),
65+
truncated=truncated,
66+
index=index,
67+
data=data,
68+
)
69+
70+
71+
class TestAggregateResultSetsByIndex(unittest.TestCase):
72+
def test_merges_parts_with_same_index_into_one_result_set(self):
73+
merged = aggregate_result_sets_by_index([_rs(0, [1, 2]), _rs(0, [3, 4]), _rs(0, [5])])
74+
75+
self.assertEqual(len(merged), 1)
76+
self.assertEqual(merged[0].index, 0)
77+
self.assertEqual(merged[0].rows, [1, 2, 3, 4, 5])
78+
79+
def test_keeps_distinct_indexes_separate_and_ordered(self):
80+
merged = aggregate_result_sets_by_index([_rs(0, [1]), _rs(0, [2]), _rs(1, [3]), _rs(2, [4]), _rs(2, [5])])
81+
82+
self.assertEqual([rs.index for rs in merged], [0, 1, 2])
83+
self.assertEqual([rs.rows for rs in merged], [[1, 2], [3], [4, 5]])
84+
85+
def test_schema_kept_from_first_part_when_later_parts_omit_it(self):
86+
merged = aggregate_result_sets_by_index([_rs(0, [1], columns=["id", "name"]), _rs(0, [2], columns=[])])
87+
88+
self.assertEqual(merged[0].columns, ["id", "name"])
89+
self.assertEqual(merged[0].rows, [1, 2])
90+
91+
def test_truncated_flag_is_propagated_from_any_part(self):
92+
merged = aggregate_result_sets_by_index([_rs(0, [1], truncated=False), _rs(0, [2], truncated=True)])
93+
94+
self.assertTrue(merged[0].truncated)
95+
96+
def test_arrow_data_is_concatenated(self):
97+
merged = aggregate_result_sets_by_index([_rs(0, [], data=b"aa"), _rs(0, [], data=b"bb")])
98+
99+
self.assertEqual(merged[0].data, b"aabb")
100+
101+
def test_interleaved_parts_are_merged_by_index(self):
102+
merged = aggregate_result_sets_by_index([_rs(0, [1]), _rs(1, [2]), _rs(0, [3]), _rs(1, [4])])
103+
104+
self.assertEqual([rs.index for rs in merged], [0, 1])
105+
self.assertEqual([rs.rows for rs in merged], [[1, 3], [2, 4]])
106+
107+
def test_empty_input_returns_empty_list(self):
108+
self.assertEqual(aggregate_result_sets_by_index([]), [])
109+
110+
60111
class TestRetryOperationSync(unittest.TestCase):
61112
def test_retry_reacquires_invalidated_session_before_first_use(self):
62113
pool = _make_pool(size=1)

0 commit comments

Comments
 (0)