Skip to content

Commit 05b3775

Browse files
authored
perf(spanner): optimize single-chunk results and single-row lookups (#18602)
Optimize the streamed result set decoding pipeline for point queries and small result sets, and fix async support in `to_dict_list()`. Key changes: - Zero-copy protobuf values: In `_consume_next()`, avoid copying `response_pb.values` into a Python list on unchunked responses. Downstream decoders read or slice directly from the protobuf container. - Fast paths in `_merge_values()`: - Point lookups (`total_values == width`): Directly decode and append the single row via `_append_single_row()`, bypassing row count arithmetic, stride looping, and index allocations. - Complete chunks (`total_values % width == 0`): Skip partial-row checking and directly batch-decode complete rows. - Single-indexing in eager decoding: Cache cell lookups using the walrus operator `(cell := values[...])` instead of indexing twice. - Direct consumption in `one()` / `one_or_none()`: Replace the generator state machine and iterator setup with a direct `_consume_next()` loop, reducing latency on point queries and avoiding Python 3.14 async generator cleanup warnings when queries short-circuit. - Faster, async-safe `to_dict_list()`: - Cache the column name list once on row 0 instead of re-inspecting protobuf metadata on every row (~2x speedup). - Add `@CrossSync.convert` with `async for` in `_async/streamed.py`, fixing a latent bug where calling `to_dict_list()` on an async streamed result set raised a TypeError.
1 parent a7678fd commit 05b3775

5 files changed

Lines changed: 1060 additions & 105 deletions

File tree

‎packages/google-cloud-spanner/google/cloud/spanner_v1/_async/streamed.py‎

Lines changed: 94 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ def _decode_eager_rows(self, values, values_offset, batch_end, width):
165165
decoder = self._decoders[0]
166166
self._rows.extend(
167167
[
168-
[None if value.HasField("null_value") else decoder(value)]
169-
for value in values[values_offset:batch_end]
168+
[None if cell.HasField("null_value") else decoder(cell)]
169+
for cell in values[values_offset:batch_end]
170170
]
171171
)
172172
else:
@@ -177,16 +177,51 @@ def _decode_eager_rows(self, values, values_offset, batch_end, width):
177177
rows_append(
178178
[
179179
None
180-
if values[row_start + column_index].HasField("null_value")
181-
else decoders[column_index](values[row_start + column_index])
180+
if (cell := values[row_start + column_index]).HasField(
181+
"null_value"
182+
)
183+
else decoders[column_index](cell)
182184
for column_index in column_indices
183185
]
184186
)
185187

188+
def _append_single_row(self, values, width):
189+
"""Decode and append a single complete row on the fast path."""
190+
if self._lazy_decode:
191+
self._rows.append(values[:width])
192+
elif width == 1:
193+
cell = values[0]
194+
self._rows.append(
195+
[None if cell.HasField("null_value") else self._decoders[0](cell)]
196+
)
197+
else:
198+
decoders = self._decoders
199+
self._rows.append(
200+
[
201+
None
202+
if (cell := values[i]).HasField("null_value")
203+
else decoders[i](cell)
204+
for i in range(width)
205+
]
206+
)
207+
208+
def _complete_partial_row(self, values, width, total_values):
209+
"""Complete a pending partial row from a previous chunk.
210+
211+
:returns: The number of values consumed to complete or fill the partial row.
212+
"""
213+
needed = width - len(self._current_row)
214+
fill_count = min(needed, total_values)
215+
self._append_to_current_row(values[:fill_count])
216+
if len(self._current_row) == width:
217+
self._rows.append(self._current_row)
218+
self._current_row = []
219+
return fill_count
220+
186221
def _merge_values(self, values):
187222
"""Merge values into rows.
188223
189-
:type values: list of :class:`~google.protobuf.struct_pb2.Value`
224+
:type values: list or RepeatedCompositeContainer of :class:`~google.protobuf.struct_pb2.Value`
190225
:param values: non-chunked values from partial result set.
191226
"""
192227
if not values:
@@ -196,36 +231,44 @@ def _merge_values(self, values):
196231
if width == 0:
197232
return
198233

199-
values_offset = 0
200234
total_values = len(values)
201235

202-
# 1. Complete pending partial row from previous chunk (if any)
236+
if not self._current_row:
237+
# 1. Fast path for a single-row result (point lookup)
238+
if total_values == width:
239+
self._append_single_row(values, width)
240+
return
241+
242+
# 2. Fast path when all rows in this chunk are complete (e.g. single PartialResultSet)
243+
if total_values % width == 0:
244+
if self._lazy_decode:
245+
self._decode_lazy_rows(values, 0, total_values, width)
246+
else:
247+
self._decode_eager_rows(values, 0, total_values, width)
248+
return
249+
250+
# 3. Complete pending partial row from previous chunk (if any)
251+
values_offset = 0
203252
if self._current_row:
204-
needed = width - len(self._current_row)
205-
fill_count = min(needed, total_values)
206-
self._append_to_current_row(values[:fill_count])
207-
values_offset = fill_count
208-
if len(self._current_row) == width:
209-
self._rows.append(self._current_row)
210-
self._current_row = []
211-
else:
253+
values_offset = self._complete_partial_row(values, width, total_values)
254+
if self._current_row:
212255
return
213256

214257
remaining_values = total_values - values_offset
215258
if remaining_values == 0:
216259
return
217260

261+
# 4. Batch-decode complete rows
218262
row_count = remaining_values // width
219263
full_values_count = row_count * width
220264
batch_end = values_offset + full_values_count
221265

222-
# 2. Batch-decode complete rows
223266
if self._lazy_decode:
224267
self._decode_lazy_rows(values, values_offset, batch_end, width)
225268
else:
226269
self._decode_eager_rows(values, values_offset, batch_end, width)
227270

228-
# 3. Buffer trailing partial row remainder for the next chunk (if any)
271+
# 5. Buffer trailing partial row remainder for the next chunk (if any)
229272
if remaining_values > full_values_count:
230273
self._append_to_current_row(values[batch_end:])
231274

@@ -244,12 +287,14 @@ async def _consume_next(self):
244287
if response_pb.HasField("stats"): # last response
245288
self._stats = response.stats
246289

247-
values = list(response_pb.values)
248-
if self._pending_chunk is not None:
249-
values[0] = self._merge_chunk(values[0])
250-
251-
if response_pb.chunked_value:
252-
self._pending_chunk = values.pop()
290+
if self._pending_chunk is not None or response_pb.chunked_value:
291+
values = list(response_pb.values)
292+
if self._pending_chunk is not None:
293+
values[0] = self._merge_chunk(values[0])
294+
if response_pb.chunked_value:
295+
self._pending_chunk = values.pop()
296+
else:
297+
values = response_pb.values
253298

254299
self._merge_values(values)
255300

@@ -259,15 +304,18 @@ async def _consume_next(self):
259304
@CrossSync.convert(sync_name="__iter__")
260305
async def __aiter__(self):
261306
while True:
307+
if not self._rows:
308+
if self._done:
309+
return
310+
try:
311+
await self._consume_next()
312+
except StopAsyncIteration:
313+
return
262314
iter_rows, self._rows = self._rows, []
263315
for row in iter_rows:
264316
yield row
265317
if self._done:
266318
return
267-
try:
268-
await self._consume_next()
269-
except StopAsyncIteration:
270-
return
271319

272320
def decode_row(self, row: []) -> []:
273321
"""Decodes a row from protobuf values to Python objects. This function
@@ -327,23 +375,20 @@ async def one_or_none(self):
327375
"stream consumption has already started."
328376
)
329377

330-
# Consume the first result of the stream.
331-
# If there is no first result, then return None.
332-
iterator = self.__aiter__()
333-
try:
334-
answer = await iterator.__anext__()
335-
except StopAsyncIteration:
336-
return None
378+
while not self._done and len(self._rows) < 2:
379+
try:
380+
await self._consume_next()
381+
except StopAsyncIteration:
382+
break
337383

338-
# Attempt to consume more. This should no-op; if we get additional
339-
# rows, then this is an error case.
340-
try:
341-
await iterator.__anext__()
342-
raise ValueError("Expected one result; got more.")
343-
except StopAsyncIteration:
344-
return answer
384+
if len(self._rows) == 1:
385+
return self._rows.pop()
386+
if not self._rows:
387+
return None
388+
raise ValueError("Expected one result; got more.")
345389

346-
def to_dict_list(self):
390+
@CrossSync.convert
391+
async def to_dict_list(self):
347392
"""Return the result of a query as a list of dictionaries.
348393
In each dictionary the key is the column name and the value is the
349394
value of the that column in a given row.
@@ -353,15 +398,13 @@ def to_dict_list(self):
353398
:returns: result rows as a list of dictionaries
354399
"""
355400
rows = []
356-
for row in self:
357-
rows.append(
358-
{
359-
column: value
360-
for column, value in zip(
361-
[column.name for column in self._metadata.row_type.fields], row
362-
)
363-
}
364-
)
401+
column_names = None
402+
async for row in self:
403+
if column_names is None:
404+
column_names = [
405+
column.name for column in self._metadata.row_type.fields
406+
]
407+
rows.append(dict(zip(column_names, row)))
365408
return rows
366409

367410

0 commit comments

Comments
 (0)