Skip to content

Commit a83f50f

Browse files
vuanhphungIsaac
andcommitted
Keep returning downloaded files after a failed link fetch
A failed mid-stream link fetch now stops scheduling and is raised only once the files already downloading run out, so a reader that never needs the missing rows still succeeds, as on main. The first read that needs them gets the original error and later reads fail too. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
1 parent 6e14f55 commit a83f50f

2 files changed

Lines changed: 45 additions & 17 deletions

File tree

‎src/databricks/sql/cloudfetch/download_manager.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import threading
55

66
from concurrent.futures import CancelledError, ThreadPoolExecutor, Future
7-
from typing import Callable, List, Union, Tuple, Optional, TYPE_CHECKING
7+
from typing import Callable, List, NoReturn, Union, Tuple, Optional, TYPE_CHECKING
88

99
from databricks.sql.exc import OperationalError
1010
from databricks.sql.cloudfetch.downloader import (
@@ -66,8 +66,9 @@ def __init__(
6666
self._cancelled = threading.Event()
6767
# Set by cancel(): reads fail once they need data the cancel stopped.
6868
self._cancel_requested = False
69-
# A failed link fetch, raised again by every later read (see _schedule_downloads)
69+
# A failed link fetch, raised once the downloaded files run out
7070
self._link_error: Optional[BaseException] = None
71+
self._link_error_raised = False
7172

7273
def cancel(self) -> None:
7374
"""Stop downloading because the statement was cancelled.
@@ -108,17 +109,14 @@ def get_next_downloaded_file(
108109
next_row_offset (int): The offset of the starting row of the next file we want data from.
109110
"""
110111

111-
if self._link_error is not None:
112-
raise OperationalError(
113-
"Fetching more CloudFetch results failed earlier; the result can't "
114-
"be read further, execute the statement again"
115-
) from self._link_error
116-
if not self._cancel_requested:
112+
if not self._cancel_requested and self._link_error is None:
117113
# Make sure the download queue is always full
118114
self._schedule_downloads()
119115

120116
# No more files to download from this batch of links
121117
if len(self._download_tasks) == 0:
118+
if self._link_error is not None:
119+
self._raise_link_error(self._link_error)
122120
if self._cancel_requested and (
123121
self._pending_links or self._link_source is not None
124122
):
@@ -146,6 +144,18 @@ def get_next_downloaded_file(
146144

147145
return file
148146

147+
def _raise_link_error(self, error: BaseException) -> NoReturn:
148+
# That read loses the rows it had collected, so every later read fails too
149+
# and a retry can't continue past them.
150+
self._shutdown_manager()
151+
if not self._link_error_raised:
152+
self._link_error_raised = True
153+
raise error
154+
raise OperationalError(
155+
"Fetching more CloudFetch results failed earlier; the result can't "
156+
"be read further, execute the statement again"
157+
) from error
158+
149159
def _schedule_downloads(self):
150160
self._submit_pending_links()
151161
while (
@@ -159,12 +169,10 @@ def _schedule_downloads(self):
159169
self._max_download_threads - scheduled
160170
)
161171
except Exception as e:
162-
# This read is failing part-way, so the caller loses the rows it had
163-
# collected. Fail every later read too, so a retry cannot skip them,
164-
# and stop the downloads nobody will read.
172+
# Stop scheduling; files already downloading are still returned and
173+
# the read that needs rows past them raises (_raise_link_error)
165174
self._link_error = e
166-
self._shutdown_manager()
167-
raise
175+
return
168176
if self._cancelled.is_set():
169177
# Shut down while the request was in flight
170178
return

‎tests/unit/test_cloudfetch_prefetch.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,24 +140,34 @@ def test_reply_with_only_empty_links_is_retried_on_the_next_call(self):
140140
self.assertIsNone(manager.get_next_downloaded_file(20))
141141

142142
@patch.object(download_manager, "ResultSetDownloadHandler", _InstantHandler)
143-
def test_a_failed_link_fetch_fails_every_later_read(self):
143+
def test_a_failed_link_fetch_fails_reads_once_downloaded_files_run_out(self):
144144
manager = self._manager([_link(0)], max_download_threads=2)
145145
error = RuntimeError("FetchResults failed")
146146
source = Mock(side_effect=[error, ([_link(10)], False)])
147147
manager.set_link_source(source)
148148

149+
# The file already downloading is still returned
150+
self.assertEqual(manager.get_next_downloaded_file(0).start_row_offset, 0)
149151
with self.assertRaises(RuntimeError) as raised:
150-
manager.get_next_downloaded_file(0)
152+
manager.get_next_downloaded_file(10)
151153
self.assertIs(raised.exception, error)
152-
# Downloads nobody can read any more are stopped
153154
self.assertTrue(manager._cancelled.is_set())
154155
# Later reads raise a fresh error that points at the original one
155156
for _ in range(2):
156157
with self.assertRaises(OperationalError) as raised:
157-
manager.get_next_downloaded_file(0)
158+
manager.get_next_downloaded_file(10)
158159
self.assertIs(raised.exception.__cause__, error)
159160
self.assertEqual(source.call_count, 1)
160161

162+
@patch.object(download_manager, "ResultSetDownloadHandler", _InstantHandler)
163+
def test_with_nothing_in_flight_empty_replies_are_retried_at_once(self):
164+
manager = self._manager([], max_download_threads=2)
165+
source = Mock(side_effect=[([], True), ([], True), ([_link(0)], False)])
166+
manager.set_link_source(source)
167+
168+
self.assertEqual(manager.get_next_downloaded_file(0).start_row_offset, 0)
169+
self.assertEqual(source.call_count, 3)
170+
161171
@patch.object(download_manager, "ResultSetDownloadHandler", _InstantHandler)
162172
def test_shutdown_during_a_link_fetch_ends_the_stream_cleanly(self):
163173
manager = self._manager([_link(0)], max_download_threads=2)
@@ -591,6 +601,16 @@ def test_cancelled_downloads_fail_the_next_fetch_instead_of_truncating(self):
591601
with self.assertRaises(OperationalError):
592602
rs.fetchall_arrow()
593603

604+
def test_a_reader_that_never_needs_the_failed_links_is_not_failed(self):
605+
rs, backend, _ = self._result_set()
606+
backend.fetch_result_links.side_effect = DataError("FetchResults failed")
607+
608+
# Rows 0-14 come from the two initial links, which were already downloading
609+
table = rs.fetchmany_arrow(15)
610+
611+
self.assertEqual(table.column(0).to_pylist(), list(range(15)))
612+
rs.close()
613+
594614
def test_a_retry_after_a_failed_link_fetch_fails_instead_of_skipping_rows(self):
595615
rs, backend, _ = self._result_set()
596616
backend.fetch_result_links.side_effect = [

0 commit comments

Comments
 (0)