Skip to content

Commit 4b9a5e2

Browse files
committed
fix: convert rows in batches for fetchone() and cursor iteration
fetchone() fetched a single row and ran it through _convert_arrow_table, which builds a pandas DataFrame per call. That fixed cost made iterating a cursor, or any fetchone() loop such as SQLAlchemy's default result iteration, about 1 ms per row, more than 100x slower than fetchall(). ResultSet now implements fetchone(), fetchmany() and fetchall() once for all backends. fetchone() fetches and converts arraysize rows at a time and serves them from a small buffer that keeps both the converted rows and the raw batch. Every other fetch method drains that buffer first, so ordering and counts are unchanged when row and Arrow/columnar/JSON fetches are mixed, and rownumber still reports the number of rows handed to the caller. Signed-off-by: Maha Rana Yadavalli <271375718+maharanay22@users.noreply.github.com>
1 parent 61b9a7f commit 4b9a5e2

7 files changed

Lines changed: 408 additions & 120 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Release History
22

3+
# Unreleased
4+
- Fix: iterating a cursor (`for row in cursor`) and calling `fetchone()` in a loop are now about as fast as `fetchall()`. They previously converted each row through pandas on its own, costing around 1 ms per row, which made SQLAlchemy result iteration very slow. Rows are now converted `arraysize` at a time and served from an internal buffer.
5+
36
# 4.6.0 (2026-09-24)
47
- Upgrade Databricks SQL Kernel to 1.1.0; the kernel dependency is now stable and no longer experimental.
58
- Transparently auto-recover Thrift connections to Reyden / Real-Time warehouses: when a warehouse rejects the default Thrift protocol (SQLSTATE `KP001`), the session is re-opened on the kernel backend and the warehouse is remembered so later connections skip Thrift. Applies only when no backend was chosen explicitly.

‎src/databricks/sql/backend/kernel/result_set.py‎

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
import logging
3434
from collections import deque
35-
from typing import Any, Deque, List, Optional, TYPE_CHECKING, cast
35+
from typing import Any, Deque, List, TYPE_CHECKING, cast
3636

3737
import pyarrow
3838

@@ -190,37 +190,35 @@ def _drain(self) -> pyarrow.Table:
190190
# ----- Arrow fetches -----
191191

192192
def fetchall_arrow(self) -> pyarrow.Table:
193-
return self._drain()
193+
buffered = self._take_buffered_table()
194+
table = self._drain()
195+
if buffered is None:
196+
return table
197+
return pyarrow.concat_tables([buffered, table])
194198

195199
def fetchmany_arrow(self, size: int) -> pyarrow.Table:
196200
if size < 0:
197201
raise ValueError(f"fetchmany_arrow size must be >= 0, got {size}")
198202
if size == 0:
199203
return pyarrow.Table.from_batches([], schema=self._schema)
200-
self._ensure_buffered(size)
201-
return self._take_buffered(size)
204+
buffered = self._take_buffered_table(size)
205+
n_remaining = size if buffered is None else size - buffered.num_rows
206+
self._ensure_buffered(n_remaining)
207+
table = self._take_buffered(n_remaining)
208+
if buffered is None:
209+
return table
210+
return pyarrow.concat_tables([buffered, table])
202211

203212
# ----- Row fetches -----
204213

205-
def fetchone(self) -> Optional[Row]:
206-
self._ensure_buffered(1)
207-
if self._buffered_rows() == 0:
208-
return None
209-
table = self._take_buffered(1)
210-
rows = self._convert_arrow_table(table)
211-
return rows[0] if rows else None
214+
def _fetchmany_table(self, size: int) -> pyarrow.Table:
215+
return self.fetchmany_arrow(size)
212216

213-
def fetchmany(self, size: int) -> List[Row]:
214-
if size < 0:
215-
raise ValueError(f"fetchmany size must be >= 0, got {size}")
216-
if size == 0:
217-
return []
218-
self._ensure_buffered(size)
219-
table = self._take_buffered(size)
220-
return self._convert_arrow_table(table)
217+
def _fetchall_table(self) -> pyarrow.Table:
218+
return self.fetchall_arrow()
221219

222-
def fetchall(self) -> List[Row]:
223-
return self._convert_arrow_table(self._drain())
220+
def _convert_table(self, table: pyarrow.Table) -> List[Row]:
221+
return self._convert_arrow_table(table)
224222

225223
def close(self) -> None:
226224
"""Close the underlying kernel handle and notify the backend.

‎src/databricks/sql/backend/sea/result_set.py‎

Lines changed: 36 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, List, Optional, TYPE_CHECKING
3+
from typing import Any, List, Optional, TYPE_CHECKING, Union
44

55
import logging
66

@@ -19,6 +19,7 @@
1919
from databricks.sql.backend.sea.queue import JsonQueue, SeaResultSetQueueFactory
2020
from databricks.sql.backend.types import ExecuteResponse
2121
from databricks.sql.result_set import ResultSet
22+
from databricks.sql.utils import concat_table_chunks
2223

2324
logger = logging.getLogger(__name__)
2425

@@ -160,10 +161,11 @@ def fetchmany_json(self, size: int) -> List[List[str]]:
160161
if size < 0:
161162
raise ValueError(f"size argument for fetchmany is {size} but must be >= 0")
162163

163-
results = self.results.next_n_rows(size)
164+
buffered = self._take_buffered_table(size) or []
165+
results = self.results.next_n_rows(size - len(buffered))
164166
self._next_row_index += len(results)
165167

166-
return results
168+
return buffered + results
167169

168170
def fetchall_json(self) -> List[List[str]]:
169171
"""
@@ -173,10 +175,11 @@ def fetchall_json(self) -> List[List[str]]:
173175
Columnar table containing all remaining rows
174176
"""
175177

178+
buffered = self._take_buffered_table() or []
176179
results = self.results.remaining_rows()
177180
self._next_row_index += len(results)
178181

179-
return results
182+
return buffered + results
180183

181184
def fetchmany_arrow(self, size: int) -> "pyarrow.Table":
182185
"""
@@ -196,71 +199,53 @@ def fetchmany_arrow(self, size: int) -> "pyarrow.Table":
196199
if size < 0:
197200
raise ValueError(f"size argument for fetchmany is {size} but must be >= 0")
198201

199-
results = self.results.next_n_rows(size)
200202
if isinstance(self.results, JsonQueue):
201-
results = self._convert_json_to_arrow_table(results)
203+
return self._convert_json_to_arrow_table(self.fetchmany_json(size))
202204

205+
buffered = self._take_buffered_table(size)
206+
n_buffered = 0 if buffered is None else buffered.num_rows
207+
results = self.results.next_n_rows(size - n_buffered)
203208
self._next_row_index += results.num_rows
204209

205-
return results
210+
return self._prepend_buffered(buffered, results)
206211

207212
def fetchall_arrow(self) -> "pyarrow.Table":
208213
"""
209214
Fetch all remaining rows as an Arrow table.
210215
"""
211216

212-
results = self.results.remaining_rows()
213217
if isinstance(self.results, JsonQueue):
214-
results = self._convert_json_to_arrow_table(results)
218+
return self._convert_json_to_arrow_table(self.fetchall_json())
215219

220+
buffered = self._take_buffered_table()
221+
results = self.results.remaining_rows()
216222
self._next_row_index += results.num_rows
217223

218-
return results
219-
220-
def fetchone(self) -> Optional[Row]:
221-
"""
222-
Fetch the next row of a query result set, returning a single sequence,
223-
or None when no more data is available.
224+
return self._prepend_buffered(buffered, results)
224225

225-
Returns:
226-
A single Row object or None if no more rows are available
227-
"""
226+
@staticmethod
227+
def _prepend_buffered(
228+
buffered: Optional["pyarrow.Table"], results: "pyarrow.Table"
229+
) -> "pyarrow.Table":
230+
if buffered is None:
231+
return results
232+
if results.num_rows == 0:
233+
return buffered
234+
return concat_table_chunks([buffered, results])
228235

236+
def _fetchmany_table(self, size: int) -> Union[List[List[str]], "pyarrow.Table"]:
229237
if isinstance(self.results, JsonQueue):
230-
res = self._create_json_table(self.fetchmany_json(1))
231-
else:
232-
res = self._convert_arrow_table(self.fetchmany_arrow(1))
233-
234-
return res[0] if res else None
235-
236-
def fetchmany(self, size: int) -> List[Row]:
237-
"""
238-
Fetch the next set of rows of a query result, returning a list of rows.
239-
240-
Args:
241-
size: Number of rows to fetch (defaults to arraysize if None)
242-
243-
Returns:
244-
List of Row objects
245-
246-
Raises:
247-
ValueError: If size is negative
248-
"""
238+
return self.fetchmany_json(size)
239+
return self.fetchmany_arrow(size)
249240

241+
def _fetchall_table(self) -> Union[List[List[str]], "pyarrow.Table"]:
250242
if isinstance(self.results, JsonQueue):
251-
return self._create_json_table(self.fetchmany_json(size))
252-
else:
253-
return self._convert_arrow_table(self.fetchmany_arrow(size))
254-
255-
def fetchall(self) -> List[Row]:
256-
"""
257-
Fetch all remaining rows of a query result, returning them as a list of rows.
258-
259-
Returns:
260-
List of Row objects containing all remaining rows
261-
"""
243+
return self.fetchall_json()
244+
return self.fetchall_arrow()
262245

246+
def _convert_table(
247+
self, table: Union[List[List[str]], "pyarrow.Table"]
248+
) -> List[Row]:
263249
if isinstance(self.results, JsonQueue):
264-
return self._create_json_table(self.fetchall_json())
265-
else:
266-
return self._convert_arrow_table(self.fetchall_arrow())
250+
return self._create_json_table(table)
251+
return self._convert_arrow_table(table)

0 commit comments

Comments
 (0)