Skip to content

Iterating a cursor or calling fetchone() in a loop is 100x+ slower than fetchall() (affects SQLAlchemy) (#981) - #1007

Open
peco-engineer-bot[bot] wants to merge 29 commits into
mainfrom
ai/issue-981
Open

peco-engineer-bot[bot] wants to merge 29 commits into
mainfrom
ai/issue-981

Conversation

@peco-engineer-bot

Copy link
Copy Markdown
Contributor

Summary

Automated fix for #981 — Iterating a cursor or calling fetchone() in a loop is 100x+ slower than fetchall() (affects SQLAlchemy).

fetchone() and cursor iteration now convert rows arraysize at a time instead of building a pandas DataFrame for every row; this is in place on the Thrift, SEA and kernel backends. The cause: fetchone() fetched and converted a single row, and iteration calls fetchone(). ResultSet now has a small row buffer that keeps each raw chunk (Arrow table, ColumnTable or JSON rows) next to its converted rows. fetchone() refills it one batch at a time, and every other fetch method (fetchmany, fetchall, fetchmany_arrow, fetchall_arrow, the columnar and JSON variants) takes from the buffer before reading the queue, so row order is unchanged. _next_row_index and rownumber still count rows actually handed to the caller. The queue is only read again once the buffer is empty, so Thrift's expected_row_start_offset stays correct. Testing: all the listed tests failed before and pass now against the live warehouse. The full unit suite (-m "not realkernel") passes with 1274 tests, and no existing test was changed. Two kernel async tests in test_kernel_backend.py fail with a NotSupportedError saying the installed kernel wheel is too old. That error comes from get_query_state in kernel/client.py, which this change doesn't touch, but I couldn't confirm it also fails on the original code.

Root cause & plan

Root cause: ResultSet.__iter__ (src/databricks/sql/result_set.py) calls self.fetchone() once per row, and every backend's fetchone() fetches and converts exactly one row. That covers ThriftResultSet.fetchone (_convert_arrow_table(self.fetchmany_arrow(1)) or _convert_columnar_table(self.fetchmany_columnar(1))), SeaResultSet.fetchone in backend/sea/result_set.py (_convert_arrow_table(self.fetchmany_arrow(1)) or _create_json_table(self.fetchmany_json(1))) and KernelResultSet.fetchone in backend/kernel/result_set.py (_convert_arrow_table(self._take_buffered(1))). On the Arrow path _convert_arrow_table builds a dtype map, renames columns, calls table.to_pandas() and then df.to_numpy() for that one row. So every row pays the fixed cost of building a pandas DataFrame (about 0.6–0.8 ms), which makes iteration or a fetchone() loop over N rows run N DataFrame conversions instead of about N/arraysize. Cursor.__iter__ and Cursor.fetchone in client.py simply delegate, so SQLAlchemy's per-row fetchone() hits this directly.
Files: src/databricks/sql/result_set.py, src/databricks/sql/backend/sea/result_set.py, src/databricks/sql/backend/kernel/result_set.py, tests/e2e/test_driver.py, tests/e2e/test_kernel_backend.py, tests/unit/test_fetches.py, tests/unit/test_sea_result_set.py
Planned coverage:

  • New E2E test in tests/e2e/test_driver.py, parametrized like the neighbouring tests ({}, {use_sea: True, use_cloud_fetch: False, enable_query_result_lz4_compression: False}, {use_sea: True}). It runs SELECT id, CAST(id AS STRING) AS s FROM range(N) with N=5000 and the default arraysize (10000). It wraps ResultSet._convert_arrow_table with unittest.mock.patch.object(ResultSet, '_convert_arrow_table', autospec=True, side_effect=orig) to count calls, then iterates for row in cursor. It asserts: (a) exactly N rows come back in order, with id values 0..N-1 and s == str(id), matching a separate fetchall(); (b) the conversion call count is at most ceil(N/arraysize)+2, so a handful rather than N. A call count gives a deterministic red/green signal instead of a flaky timing check. Today the count is about N, so the test fails. (Thrift (default backend), Arrow path: a fetchone()/iteration loop converts one row at a time)
  • Same setup as above, but the loop is while (r := cursor.fetchone()) is not None (written in a py3.8-safe form) instead of iteration. It asserts the same row values and order and the same bound on _convert_arrow_table calls. It fails today because each fetchone() makes its own conversion call. (Thrift/SEA: an explicit fetchone() loop (the SQLAlchemy path) converts one row at a time)
  • E2E test on the same backend params with SELECT * FROM range(10) and cursor.arraysize left at its default. Call fetchone() twice, expecting 0 and 1. Then fetchmany(3) returns [2,3,4]. fetchone() returns 5. fetchmany_arrow(2) returns a pyarrow table with [6,7]. fetchall() returns [8,9]. A further fetchone() returns None. A second variant uses fetchall_arrow() after a fetchone(), and also checks that cursor.rownumber equals the number of rows the caller has consumed. This guards that the new row buffer is drained first by every other fetch method and that rownumber doesn't jump by a whole batch. It passes today; it's a required companion so the fix can't reorder or drop rows. (Mixing fetchone with other fetch methods once a buffer exists (ordering and no lost rows))
  • New E2E test in tests/e2e/test_kernel_backend.py, following that file's connection pattern and run on its own. It runs the same range(N) query, counts _convert_arrow_table calls during for row in cursor and during a fetchone() loop, and asserts row correctness plus a call count of at most ceil(N/arraysize)+2. A mixed fetchone → fetchmany → fetchmany_arrow → fetchall ordering check is included. It fails today because KernelResultSet.fetchone converts _take_buffered(1) on every call. (Kernel backend (use_kernel=True): fetchone() converts per row)
  • Unit tests in tests/unit/test_fetches.py (Thrift ColumnQueue via the existing mocked-batch helpers) and the SEA result-set unit tests (JsonQueue). Mix fetchone, fetchmany, fetchall and fetchall_arrow/fetchmany_arrow, and assert values, order and rownumber. Also assert that iterating N rows calls the row-conversion function (_convert_columnar_table / _create_json_table) about N/arraysize times rather than N times. These paths are hard to force end to end against the warehouse, which is why they're unit tests; the E2E tests above still cover the main Arrow path. (Non-Arrow paths (Thrift ColumnQueue, SEA JsonQueue): buffered fetchone keeps results and ordering correct)

Files changed

  • tests/e2e/test_driver.py
  • tests/e2e/test_kernel_backend.py
  • tests/unit/test_fetches.py
  • tests/unit/test_sea_result_set.py
  • src/databricks/sql/result_set.py
  • src/databricks/sql/backend/sea/result_set.py
  • src/databricks/sql/backend/kernel/result_set.py

Test plan

  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_iteration_converts_rows_in_batches[extra_params0] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_iteration_converts_rows_in_batches[extra_params1] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_iteration_converts_rows_in_batches[extra_params2] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_loop_converts_rows_in_batches[extra_params0] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_loop_converts_rows_in_batches[extra_params1] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_loop_converts_rows_in_batches[extra_params2] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_mixed_with_other_fetches_keeps_order[extra_params0] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_mixed_with_other_fetches_keeps_order[extra_params1] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_mixed_with_other_fetches_keeps_order[extra_params2] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_then_fetchall_arrow_keeps_order[extra_params0] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_then_fetchall_arrow_keeps_order[extra_params1] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_driver.py::TestPySQLCoreSuite::test_fetchone_then_fetchall_arrow_keeps_order[extra_params2] — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_kernel_backend.py::test_iteration_converts_rows_in_batches — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_kernel_backend.py::test_fetchone_loop_converts_rows_in_batches — fails (red) against the original code, passes (green) after the fix
  • tests/e2e/test_kernel_backend.py::test_fetchone_mixed_with_other_fetches_keeps_order — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_fetches.py::ColumnarFetchoneBufferTests::test_iteration_converts_in_batches — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_fetches.py::ColumnarFetchoneBufferTests::test_fetchone_loop_converts_in_batches — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_fetches.py::ColumnarFetchoneBufferTests::test_mixed_fetches_keep_order_and_rownumber — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_fetches.py::ColumnarFetchoneBufferTests::test_fetchone_then_fetchall_arrow_keeps_order — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_sea_result_set.py::TestSeaResultSet::test_json_fetchone_converts_in_batches[iterate] — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_sea_result_set.py::TestSeaResultSet::test_json_fetchone_converts_in_batches[fetchone] — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_sea_result_set.py::TestSeaResultSet::test_json_mixed_fetches_keep_order_and_rownumber — fails (red) against the original code, passes (green) after the fix
  • tests/unit/test_sea_result_set.py::TestSeaResultSet::test_json_fetchone_then_fetchall_arrow_keeps_order — fails (red) against the original code, passes (green) after the fix

🤖 Generated by engineer-bot (bug-fix flow) — review before merge.

Not changed in this PR (out of scope)

These facets could not be fixed here. Reason given for each — please follow up as needed:

  • Two kernel async e2e tests fail because the installed kernel wheel is too old — These failures are unrelated to this bug: they come from a version check on the installed kernel wheel, not from the result-set code this fix changes.

    tests/e2e/test_kernel_backend.py::test_async_execute_polls_and_fetches_result and ::test_async_get_execution_result_is_re_callable raise NotSupportedError from _async_statement_reader in src/databricks/sql/backend/kernel/client.py:134, because the installed wheel's ExecutedAsyncStatement has no reader(). Suggested fix: upgrade the kernel wheel installed on the runner. Not confirmed against the original code, since git stash/checkout isn't available here.

…an fetchall() (affects SQLAlchemy) (#981)

Signed-off-by: peco-engineer-bot[bot] <3815206+peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Medium · 2 Nit

The buffered fetchone() approach looks correct, but the refill size needs a cap. Row order and rownumber/offset bookkeeping hold up across Thrift, SEA and kernel; the main concern is that the refill uses the full arraysize (default 100k), which makes a single fetchone() much heavier than before. Separately (nit), the copied ValueError("... %s ...", size) calls repeat an older bug: the message comes out as a tuple instead of a formatted string.

Other findings

  • ⚪ Nit — fetch_raw(...) consumes the rows from the queue and advances _next_row_index before convert(raw) runs. If conversion raises (for example, a bad value somewhere in the batch), the whole batch is lost: _row_buffer_raw is never assigned, and _next_row_index still counts those rows. Before this change a conversion failure lost at most one row. An error in any row of the batch also now surfaces on the very first fetchone(), ahead of rows that would have converted fine. Consider either storing raw in the buffer (and adjusting the index) before converting, or documenting the new failure behaviour.
  • ⚪ Nit — The new unit coverage for Thrift only exercises the ColumnQueue path. The Arrow path is the default on Thrift and the one that depends on expected_row_start_offset. There is no unit test for it that mixes fetchone/fetchmany/fetchmany_arrow/fetchall across multiple fetch_results batches and checks both rownumber and the expected_row_start_offset passed to the backend. The kernel buffer integration has no unit tests at all (only e2e, and the kernel e2e mixed test doesn't assert rownumber). A test along the lines of make_dummy_result_set_from_batch_list with a small arraysize that asserts the offsets would lock in the invariant documented in _fetchone_buffered.

Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4418300468 at src/databricks/sql/result_set.py:206

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 3 Low

The row-buffer design looks correct: I traced row order and _next_row_index/expected_row_start_offset through the Thrift (Arrow and columnar), SEA (Arrow and JSON) and kernel paths and found no correctness bugs. The 3 low findings are about test coverage of the kernel and Thrift Arrow paths, and about latency to the first fetchone() row. Nit: the new ValueError("... %s ...", size) calls copy an older pattern that never formats size into the message.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4418360785 at src/databricks/sql/backend/kernel/result_set.py:228
  - #4418360790 at src/databricks/sql/result_set.py:558
  - #4418360798 at src/databricks/sql/result_set.py:219

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>
Addresses:
  - #4418954338 at src/databricks/sql/result_set.py:228
  - #4418954347 at src/databricks/sql/result_set.py:203
  - #4418954351 at src/databricks/sql/result_set.py:316

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Looks correct overall. I found two low issues (formatting/line length, and repeated warning logs) and nothing blocking: draining the buffer before the queue, the _next_row_index / expected_row_start_offset accounting, and close() clearing the buffer all hold up on the Thrift, SEA and kernel paths. Nit: KernelResultSet._prepend_buffered copies ThriftResultSet._prepend_buffered but uses pyarrow.concat_tables instead of the shared concat_table_chunks; consider using one helper.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419018676 at src/databricks/sql/result_set.py:231
  - #4419018680 at src/databricks/sql/result_set.py:265

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Looks correct overall: every fetch method drains the buffer before reading the queue, so row order is kept, and _next_row_index / Thrift expected_row_start_offset stay consistent on the Thrift, SEA and kernel paths. Two low-severity issues: buffered rows that converted fine are lost when a parked failed row is reached through fetchmany/fetchall, and the e2e conversion-count bounds are loose. Nits: three near-duplicate _prepend_buffered helpers (Thrift, kernel static, and the inline concat in SEA _fetch_raw_with_buffer) could be shared, and getattr(self, "_fetchone_conversion_warned", False) is unnecessary since __init__ always sets the attribute.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread tests/e2e/test_driver.py Resolved
Addresses:
  - #4419073409 at src/databricks/sql/result_set.py:216
  - #4419073413 at tests/e2e/test_driver.py:997

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 3 Low

Looks good overall: the row buffer keeps row order, rownumber and the Thrift fetch offset consistent across the Thrift, SEA and kernel backends, and the unit coverage is thorough. Three low-severity concerns: the fixed 1000-row first refill costs extra on single-fetchone() calls over wide results, there's no Thrift/SEA unit test that close() clears the buffer, and the count-mismatch InternalError path drops a chunk without saying so. Nit: there are three separate copies of the prepend/concat logic (ThriftResultSet._prepend_buffered, KernelResultSet._prepend_buffered, SeaResultSet._fetch_raw_with_buffer), and the getattr(self, "_fetchone_conversion_warned", False) guard isn't needed because __init__ already sets the attribute.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419116506 at src/databricks/sql/result_set.py:35
  - #4419116511 at src/databricks/sql/result_set.py:448
  - #4419116514 at src/databricks/sql/result_set.py:358

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Medium

The batching itself looks correct, with one medium concern. Row order, rownumber and Thrift's expected_row_start_offset all stay consistent across Thrift, SEA and the kernel backend, and unit tests cover the mixed-fetch cases. The concern is the fallback after a failed batch conversion: the shortcut that stops retrying rows one at a time can make valid rows raise instead of being returned. Nits: the getattr(self, "_fetchone_refill_rows", …) / getattr(self, "_fetchone_conversion_warned", …) defaults are dead code because __init__ always sets both attributes, and fetchmany_arrow / fetchmany_columnar in ThriftResultSet duplicate the buffer-drain logic and could share a helper.

Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419207269 at src/databricks/sql/result_set.py:280

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 3 Low

Looks correct overall, with a few low-severity concerns. The row-buffer accounting holds up on all three backends: _next_row_index is decremented on refill and re-incremented as rows are taken, the queue is only re-read once the buffer is empty, and the Thrift offset invariant is kept. The remaining concerns are a formatting issue, the cost of the bisection fallback when every row fails, and buffer-drain logic duplicated across backends. Nits: the getattr(self, "_fetchone_refill_rows", …) and getattr(self, "_fetchone_conversion_warned", …) defaults are redundant, since __init__ always sets both and _row_buffer_rows is accessed without a default anyway.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419261192 at src/databricks/sql/result_set.py:44
  - #4419261198 at src/databricks/sql/result_set.py:265
  - #4419261206 at src/databricks/sql/backend/sea/result_set.py:280

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good overall, with one low concern. The row buffer is integrated consistently across the Thrift, SEA and kernel backends. I checked that _next_row_index, and so Thrift's expected_row_start_offset, stays aligned through refills and mixed fetches, and that ColumnTable.slice, num_rows and concat_table_chunks support the columnar raw buffer. Nit: KernelResultSet._prepend_buffered (staticmethod, pyarrow.concat_tables) and ThriftResultSet._prepend_buffered (instance method, concat_table_chunks) are near-duplicates with slightly different concat semantics. The base class could host a single version.

Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419314538 at src/databricks/sql/result_set.py:395

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Looks good overall. The row buffer is drained consistently by every fetch method on the Thrift, SEA and kernel backends, and the ordering, rownumber and Thrift expected_row_start_offset invariants are well covered by unit tests. Two low-severity concerns: some defensive getattr calls that can never take effect, and the SEA metadata filters, which read the queue directly and skip the buffer.

Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419356673 at src/databricks/sql/result_set.py:382
  - #4419356679 at src/databricks/sql/backend/sea/result_set.py:178

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks correct. The row buffer is drained ahead of the queue in every fetch path (Thrift, SEA JSON and Arrow, kernel), _next_row_index / expected_row_start_offset stay in step, and the tests cover mixed fetch ordering, close() and failure handling well. One low-severity note on how complex the error fallback is. Nit: _prepend_buffered is written twice, once in ThriftResultSet using concat_table_chunks and once in KernelResultSet using pyarrow.concat_tables, and could live once in the base class.

Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419399913 at src/databricks/sql/result_set.py:395

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

I found no correctness bugs and have two low-severity concerns, both about the conversion-failure path in result_set.py. I traced the Thrift, SEA and kernel paths: they keep row order and rownumber correct, and Thrift's expected_row_start_offset stays in step. Tests cover the mixed fetch sequences well. The bisection fallback adds a lot of complexity for a performance fix, and reviewers may want to decide whether keeping exact per-row error behaviour is worth it.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419434164 at src/databricks/sql/result_set.py:383
  - #4419434169 at src/databricks/sql/result_set.py:307

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Looks correct. The fetchone row buffer keeps row order, rownumber and Thrift's expected_row_start_offset consistent on the Thrift, SEA and kernel paths, and the unit tests cover the mixed-fetch interleavings well. My two findings are both low: a test gap on the filters change and a confusing duplicated helper. Nit: the conversion-failure bisection and _FailedRow handling adds a lot of complexity to what is a performance fix, so consider whether that much extra surface is worth carrying.

Addresses:
  - #4419479174 at src/databricks/sql/backend/sea/utils/filters.py:179
  - #4419479176 at src/databricks/sql/backend/kernel/result_set.py:211

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Looks good. The row buffer gives correct ordering and rownumber / expected_row_start_offset accounting on the Thrift, SEA and kernel backends, and the unit tests cover it well. Only two minor robustness notes. Nit: the kernel e2e test_fetchone_mixed_with_other_fetches_keeps_order doesn't assert rownumber, while the test_driver.py version does.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419518440 at src/databricks/sql/result_set.py:417
  - #4419518444 at src/databricks/sql/result_set.py:258

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Medium · 1 Low

The batching approach is sound and the tests are thorough. I found one medium issue: if converting a refill is interrupted, up to 1000 rows are lost without any error. There is also one low logging concern. Separately, the conversion-failure bisection, _FailedRow placeholders and the refill ramp add a lot of complexity to a performance fix, so maintainers may want to weigh whether keeping per-row error semantics is worth it.

Comment thread src/databricks/sql/result_set.py Resolved
Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419566436 at src/databricks/sql/result_set.py:391
  - #4419566437 at src/databricks/sql/result_set.py:318

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks correct overall. The row-buffer drain order, the rownumber/expected_row_start_offset bookkeeping and close() handling are consistent across the Thrift, SEA and kernel backends, and well tested. One low-severity inconsistency: the bisection fallback still treats MemoryError/RecursionError as data errors. Separately, the bisection/poisoning machinery adds a lot of new surface (~300 lines) to what is mainly a perf fix, and maintainers may want to weigh that.

rows.extend(convert(piece))
return
except Exception as e:
error = e

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — _fetchone_buffered re-raises _NON_DATA_CONVERSION_ERRORS (MemoryError, RecursionError) without bisecting, but the bisection here catches them anyway. MemoryError and RecursionError both subclass Exception, so the except Exception as e in convert_range grabs them when they happen on a sub-slice.

Scenario: the first batch fails with a data error (say a bad timestamp), so bisection starts. Converting one of the halves then hits MemoryError. That MemoryError is stored as a _FailedRow for every row in the range, and the bisection keeps going with more conversions under memory pressure. This is the case the module constant says it avoids. The caller then sees a MemoryError attributed to individual rows, and the result set is not marked as having lost rows (_lost_rows_error), even though the failure has nothing to do with the data.

Fix: add except _NON_DATA_CONVERSION_ERRORS: raise before except Exception inside convert_range, so these errors behave the same during bisection as they do at the top level. A unit test where the converter raises ValueError on the full batch and MemoryError on a half would cover it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reviewer was right, so I made the change. The bisection code in convert_range now has an except _NON_DATA_CONVERSION_ERRORS: raise before except Exception. If a sub-slice hits MemoryError or RecursionError, bisection stops and the error propagates, same as at the top level. Before raising, _fetchone_buffered already sets _lost_rows_error, so every later fetch on that result set raises InternalError. I added test_fetchone_memory_error_during_bisection_stops_bisecting in tests/unit/test_fetches.py: the full batch raises ValueError and the first half raises MemoryError. The test checks that conversion is attempted only twice (the 4-row batch, then the 2-row half), that the buffer is empty, and that the next fetchone() raises InternalError. `tests/unit -m "not r

Pushed 4a4bc03.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reply. The checked-out head doesn't contain the fix described in it, though. In src/databricks/sql/result_set.py, convert_range still goes straight from rows.extend(convert(piece)) to except Exception as e: error = e, with no except _NON_DATA_CONVERSION_ERRORS: raise before it. The only re-raise of those errors is still the top-level one in _fetchone_buffered. I also couldn't find test_fetchone_memory_error_during_bisection_stops_bisecting in tests/unit/test_fetches.py. It's possible 4a4bc03 hasn't reached this branch yet, so this thread stays open until the change shows up in the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛔ NEEDS HUMAN DECISION — the bots can't resolve this thread; a maintainer's input is required.

No further code change is needed. Commit 4a4bc03 has reached the branch since your last check, so the fix is now in the code. In src/databricks/sql/result_set.py, convert_range (lines 311-314) has except _NON_DATA_CONVERSION_ERRORS: raise before except Exception as e. The test test_fetchone_memory_error_during_bisection_stops_bisecting is in tests/unit/test_fetches.py at line 797. I ran pytest tests/unit/test_fetches.py -k memory_error and both MemoryError tests pass. We agree on the fix and it's in place, so a human can resolve this thread.

Addresses:
  - #4419629658 at src/databricks/sql/result_set.py:312

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks correct overall, with one low-severity inconsistency in close(). I traced how the buffer drains on Thrift, SEA and kernel: row order is preserved, _next_row_index/expected_row_start_offset stay aligned because the queue is only read once the buffer is empty, and the SEA filters now pick up buffered rows. Unit coverage for the new paths is thorough. The fetchone() error handling (bisection, lost-row poisoning) adds a lot of complexity for a performance fix, but it is well tested.

Comment thread src/databricks/sql/result_set.py Resolved
Addresses:
  - #4419671350 at src/databricks/sql/result_set.py:567

Signed-off-by: peco-engineer-bot[bot] <peco-engineer-bot[bot]@users.noreply.github.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues identified by the review bot.

This branch was previously deployed

1 inactive deployment
azure-prod — 02b8baf3 Deployed Oct 30, 2026 by peco-review-bot[bot] via followup #1376
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

engineer-bot Maintainer-applied gate: triggers engineer-bot (bug-fix on issue / take-over on PR).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants