Skip to content

Commit 44077a0

Browse files
vuanhphungIsaac
andcommitted
perf(thrift): read the reply in 1 MiB chunks instead of buffering it whole
Reading the whole body in flush() held a second copy of it during decode and, on urllib3 1.26, turned a truncated 503/429 body into a ProtocolError that skipped the status-based retry. The transport now refills from urllib3 in 1 MiB chunks (or a whole field if larger), like TBufferedTransport, so truncated bodies fail inside decode as on main. Peak heap for a 100 MiB multi-batch reply drops from ~200 to ~103 MiB at the same speed. Tests now run the decode paths under both the C and the pure-Python protocol, and cover the truncated-503 retry. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
1 parent 4a8e377 commit 44077a0

3 files changed

Lines changed: 121 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Unreleased
44
- Fix inline parameters (`use_inline_params=True`) so a sequence directly after `IN` renders as a list again: `x IN %(ids)s` renders as `x IN (1,2,3)` instead of `x IN ARRAY(1,2,3)`, which Spark rejects. Inside the list, tuples render as rows for a multi-column `IN`, and a `set` or `frozenset` is accepted again. Sequences anywhere else still render as `ARRAY(...)` ([#629](https://github.com/databricks/databricks-sql-python/issues/629))
5-
- Speed up Thrift response handling: decode replies from a buffered body with Thrift's C-accelerated protocol, and stop stringifying every request/response for the default no-op debug logger. Wide result schemas, metadata calls with many rows, and inline Arrow results benefit most.
5+
- Speed up Thrift response handling: read replies in 1 MiB chunks and decode them with Thrift's C-accelerated protocol, and stop stringifying every request/response for the default no-op debug logger. Wide result schemas, metadata calls with many rows, and inline Arrow results benefit most.
66

77
# 4.9.0 (2026-11-04)
88
- Upgrade Databricks SQL Kernel to 1.4.0, which keeps Lakehouse Real-Time session affinity and flushes shared telemetry only after the final session closes.

‎src/databricks/sql/auth/thrift_http_client.py‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,17 +157,34 @@ def close(self):
157157
self.__resp = None
158158
self.__rbuf = BytesIO()
159159

160+
# Thrift decodes the reply one field at a time, and one urllib3 read per field
161+
# costs far more than the decode, so the body is read in chunks of this size.
162+
_READ_CHUNK_SIZE = 1024 * 1024
163+
160164
def read(self, sz):
165+
ret = self.__rbuf.read(sz)
166+
if ret:
167+
return ret
168+
self.__rbuf = BytesIO(self.__resp.read(max(sz, self._READ_CHUNK_SIZE)))
161169
return self.__rbuf.read(sz)
162170

163-
# CReadableTransport: lets TBinaryProtocolAccelerated decode the buffered body in C.
171+
# CReadableTransport: lets TBinaryProtocolAccelerated decode the chunks in C.
164172
@property
165173
def cstringio_buf(self):
166174
return self.__rbuf
167175

168176
def cstringio_refill(self, partialread, reqlen):
169-
# The whole response body is already buffered, so a refill means it was truncated.
170-
raise EOFError()
177+
# As in TBufferedTransport: read one more chunk, or all of a larger field.
178+
data = partialread
179+
if reqlen < self._READ_CHUNK_SIZE:
180+
data += self.__resp.read(self._READ_CHUNK_SIZE)
181+
while len(data) < reqlen:
182+
chunk = self.__resp.read(reqlen - len(data))
183+
if not chunk:
184+
raise EOFError()
185+
data += chunk
186+
self.__rbuf = BytesIO(data)
187+
return self.__rbuf
171188

172189
def isOpen(self):
173190
return self.__resp is not None
@@ -218,12 +235,7 @@ def flush(self):
218235
self.code = self.__resp.status
219236
self.message = self.__resp.reason
220237
self.headers = self.__resp.headers
221-
222-
# Thrift decodes the reply one field at a time; reading each field
223-
# straight from urllib3 costs far more than the decode itself. The
224-
# trade-off: the raw body is held alongside the decoded objects until
225-
# close() at the end of the RPC; for fetches it is bounded by maxBytes.
226-
self.__rbuf = BytesIO(self.__resp.read())
238+
self.__rbuf = BytesIO()
227239

228240
logger.info(
229241
"HTTP Response with status code {}, message: {}".format(

‎tests/unit/test_thrift_backend.py‎

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import OrderedDict
22
from decimal import Decimal
3+
from io import BytesIO
34
import itertools
45
import logging
56
import unittest
@@ -377,7 +378,7 @@ def test_binary_protocol_reads_result_larger_than_thrift_default_limit(self):
377378

378379
@staticmethod
379380
def _http_client_with_reply(reply_bytes):
380-
"""A real THttpClient whose connection pool returns ``reply_bytes``."""
381+
"""A real THttpClient whose connection pool streams ``reply_bytes``."""
381382
from databricks.sql.auth.thrift_http_client import THttpClient
382383

383384
http_client = THttpClient(
@@ -388,14 +389,14 @@ def _http_client_with_reply(reply_bytes):
388389
http_client.setCustomHeaders({"User-Agent": "test"})
389390
http_client.open()
390391
mock_resp = Mock(status=200, reason="OK", headers={})
391-
mock_resp.read.return_value = reply_bytes
392+
mock_resp.read.side_effect = BytesIO(reply_bytes).read
392393
http_client._THttpClient__pool = Mock()
393394
http_client._THttpClient__pool.request.return_value = mock_resp
394395
return http_client, mock_resp
395396

396397
@staticmethod
397-
def _fetch_results_reply(batch):
398-
"""Wire bytes of a FetchResults REPLY carrying one inline Arrow batch."""
398+
def _fetch_results_reply(batches):
399+
"""Wire bytes of a FetchResults REPLY carrying inline Arrow ``batches``."""
399400
from thrift.protocol import TBinaryProtocol
400401
from thrift.transport import TTransport
401402
from databricks.sql.thrift_api.TCLIService import TCLIService
@@ -406,7 +407,10 @@ def _fetch_results_reply(batch):
406407
results=ttypes.TRowSet(
407408
startRowOffset=0,
408409
rows=[],
409-
arrowBatches=[ttypes.TSparkArrowBatch(batch=batch, rowCount=1)],
410+
arrowBatches=[
411+
ttypes.TSparkArrowBatch(batch=batch, rowCount=1)
412+
for batch in batches
413+
],
410414
),
411415
)
412416
reply = TTransport.TMemoryBuffer()
@@ -427,66 +431,128 @@ def _fetch_results_req():
427431
operationHandle=op_handle, orientation=0, maxRows=1
428432
)
429433

430-
def _accelerated_client(self, http_client):
431-
from thrift.protocol import TBinaryProtocol
434+
@staticmethod
435+
def _protocol_client(http_client, protocol_class):
432436
from databricks.sql.thrift_api.TCLIService import TCLIService
433437
from databricks.sql.backend.thrift_backend import (
434438
THRIFT_BINARY_PROTOCOL_STRING_LENGTH_LIMIT,
435439
THRIFT_BINARY_PROTOCOL_CONTAINER_LENGTH_LIMIT,
436440
)
437441

438-
protocol = TBinaryProtocol.TBinaryProtocolAccelerated(
442+
protocol = protocol_class(
439443
http_client,
440444
string_length_limit=THRIFT_BINARY_PROTOCOL_STRING_LENGTH_LIMIT,
441445
container_length_limit=THRIFT_BINARY_PROTOCOL_CONTAINER_LENGTH_LIMIT,
442446
)
443447
return protocol, TCLIService.Client(protocol)
444448

445-
def test_http_client_buffers_reply_and_decodes_with_accelerated_protocol(self):
446-
"""The reply body is read from urllib3 once and decoded from memory via
447-
TBinaryProtocolAccelerated, for payloads above thrift's ~15.6 MiB
448-
default limit. Runs on both the C and the pure-Python fallback path."""
449-
from thrift.transport import TTransport
449+
@staticmethod
450+
def _protocol_classes():
451+
"""The C-accelerated protocol the backend uses, and the pure-Python
452+
protocol it falls back to when thrift's C extension isn't built."""
453+
from thrift.protocol import TBinaryProtocol
450454

451-
batch = b"x" * (20 * 1024 * 1024)
452-
http_client, mock_resp = self._http_client_with_reply(
453-
self._fetch_results_reply(batch)
455+
return (
456+
TBinaryProtocol.TBinaryProtocolAccelerated,
457+
TBinaryProtocol.TBinaryProtocol,
454458
)
455-
self.assertIsInstance(http_client, TTransport.CReadableTransport)
456-
_, client = self._accelerated_client(http_client)
457459

458-
result = client.FetchResults(self._fetch_results_req())
460+
def test_http_client_reads_reply_in_chunks(self):
461+
"""The reply is pulled from urllib3 in a few chunk-sized reads: not one
462+
read per field, and never the whole body at once. Includes a field
463+
larger than a chunk and than thrift's ~15.6 MiB default limit."""
464+
from thrift.transport import TTransport
459465

460-
self.assertEqual(result.results.arrowBatches[0].batch, batch)
461-
mock_resp.read.assert_called_once_with()
466+
small = [bytes([i % 256]) * 1000 for i in range(3000)]
467+
big = b"x" * (20 * 1024 * 1024)
468+
reply = self._fetch_results_reply(small + [big])
469+
for protocol_class in self._protocol_classes():
470+
with self.subTest(protocol=protocol_class.__name__):
471+
http_client, mock_resp = self._http_client_with_reply(reply)
472+
self.assertIsInstance(http_client, TTransport.CReadableTransport)
473+
_, client = self._protocol_client(http_client, protocol_class)
474+
475+
result = client.FetchResults(self._fetch_results_req())
462476

463-
http_client.close()
464-
self.assertEqual(http_client.read(1), b"")
477+
self.assertEqual(
478+
[b.batch for b in result.results.arrowBatches], small + [big]
479+
)
480+
read_sizes = [c.args[0] for c in mock_resp.read.call_args_list]
481+
self.assertLess(len(read_sizes), 10)
482+
self.assertLessEqual(max(read_sizes), len(big))
465483

466484
@unittest.skipUnless(
467485
_HAS_THRIFT_FASTBINARY, "thrift C extension (fastbinary) not built"
468486
)
469487
def test_http_client_reply_is_decoded_by_thrift_c_extension(self):
470488
"""TBinaryProtocolAccelerated silently falls back to pure Python without
471-
fastbinary, so assert the C decoder actually ran on the buffered body."""
489+
fastbinary, so assert the C decoder actually ran on the streamed body."""
490+
from thrift.protocol import TBinaryProtocol
491+
472492
batch = b"x" * (20 * 1024 * 1024)
473-
http_client, _ = self._http_client_with_reply(self._fetch_results_reply(batch))
474-
protocol, client = self._accelerated_client(http_client)
493+
http_client, _ = self._http_client_with_reply(
494+
self._fetch_results_reply([batch])
495+
)
496+
protocol, client = self._protocol_client(
497+
http_client, TBinaryProtocol.TBinaryProtocolAccelerated
498+
)
475499
protocol._fast_decode = Mock(wraps=protocol._fast_decode)
476500

477501
result = client.FetchResults(self._fetch_results_req())
478502

479503
self.assertEqual(result.results.arrowBatches[0].batch, batch)
480504
protocol._fast_decode.assert_called()
481505

482-
def test_http_client_cstringio_refill_on_truncated_reply_raises(self):
483-
http_client, _ = self._http_client_with_reply(b"")
484-
with self.assertRaises(EOFError):
485-
http_client.cstringio_refill(b"", 4)
506+
def test_http_client_truncated_reply_raises_eof(self):
507+
reply = self._fetch_results_reply([b"x" * 100])
508+
for protocol_class in self._protocol_classes():
509+
with self.subTest(protocol=protocol_class.__name__):
510+
http_client, _ = self._http_client_with_reply(reply[:40])
511+
_, client = self._protocol_client(http_client, protocol_class)
512+
with self.assertRaises(EOFError):
513+
client.FetchResults(self._fetch_results_req())
514+
515+
@patch("time.sleep")
516+
def test_truncated_503_reply_is_retried(self, mock_sleep):
517+
"""A 503 whose body is cut short must still reach the status-based retry.
518+
urllib3 1.26 raises on an unbounded read() of a truncated body, but not on
519+
read(amt), so the body must only be read in bounded chunks."""
520+
import urllib3
521+
522+
thrift_backend = ThriftDatabricksClient(
523+
"foobar",
524+
443,
525+
"path",
526+
[("User-Agent", "test")],
527+
auth_provider=AuthProvider(),
528+
ssl_options=SSLOptions(),
529+
http_client=MagicMock(),
530+
_enable_v3_retries=False,
531+
)
532+
truncated_body = BytesIO(b"<html>Service Unavail")
533+
534+
def urllib3_1_26_read(amt=None):
535+
if amt is None:
536+
raise urllib3.exceptions.ProtocolError("IncompleteRead")
537+
return truncated_body.read(amt)
538+
539+
unavailable = Mock(status=503, reason="", headers={"Retry-After": "1"})
540+
unavailable.read.side_effect = urllib3_1_26_read
541+
ok = Mock(status=200, reason="OK", headers={})
542+
ok.read.side_effect = BytesIO(self._fetch_results_reply([b"x" * 100])).read
543+
pool = thrift_backend._transport._THttpClient__pool = Mock()
544+
pool.request.side_effect = [unavailable, ok]
545+
546+
result = thrift_backend.make_request(
547+
thrift_backend._client.FetchResults, self._fetch_results_req()
548+
)
549+
550+
self.assertEqual(result.results.arrowBatches[0].batch, b"x" * 100)
551+
self.assertEqual(pool.request.call_count, 2)
486552

487553
def test_reply_buffer_is_released_after_successful_make_request(self):
488-
"""make_request closes the transport on success too, so the buffered
489-
reply body is not kept on the connection after it has been decoded."""
554+
"""make_request closes the transport on success too, so the last chunk
555+
of the reply is not kept on the connection after it has been decoded."""
490556
batch = b"x" * (1024 * 1024)
491557
thrift_backend = ThriftDatabricksClient(
492558
"foobar",
@@ -498,7 +564,7 @@ def test_reply_buffer_is_released_after_successful_make_request(self):
498564
http_client=MagicMock(),
499565
)
500566
mock_resp = Mock(status=200, reason="OK", headers={})
501-
mock_resp.read.return_value = self._fetch_results_reply(batch)
567+
mock_resp.read.side_effect = BytesIO(self._fetch_results_reply([batch])).read
502568
thrift_backend._transport._THttpClient__pool = Mock()
503569
thrift_backend._transport._THttpClient__pool.request.return_value = mock_resp
504570

0 commit comments

Comments
 (0)