Repository navigation
perf(thrift): prefetch CloudFetch links while earlier files download - #1038
vuanhphung wants to merge 16 commits into
Conversation
Each FetchResults batch waited for its slowest file before the next batch was requested, so on average only about half of the download threads were busy. The download manager now asks the result set for more links whenever a slot frees up and no links are pending (one per free slot plus half a pool), using one thread pool for the whole result. _cloudfetch_prefetch=False restores per-batch fetching. Closing a result set now sets a cancel event that downloads check before the GET and before decompression, and cancels queued downloads. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Medium · 2 Low
Looks good overall, with one medium concern: the prefetch path discards any part of a FetchResults reply that isn't a CloudFetch link. Ordering, the memory bound and cancel-on-close look right. The kill switch is wired through every result-set constructor and has tests.
fetch_result_links raises DataError if a reply carries rows that are not CloudFetch links, instead of dropping them. set_link_source refuses a manager that is already shut down, so such a result keeps per-batch fetching. Pin that a failed link fetch reaches the caller and that fetching can resume. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 2 Low
Looks good overall, with two low-severity notes: a re-poll edge case in _schedule_downloads, and no end-to-end unit test of the result set handing link fetching to the queue. Ordering, the memory bound, cancel-on-close and the kill switch all behave as the PR describes. Nit: the link source is attached only after ThriftCloudFetchQueue.__init__ waits for the first file, so the first prefetch can't start until that download finishes.
Base the early exit on whether a download was scheduled, so a reply whose links all have zero rows waits for the next call like an empty reply. Add end-to-end tests that run ThriftResultSet over a real CloudFetch queue and download manager across several link fetches. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
|
On the review-body nit (the first prefetch waits until |
There was a problem hiding this comment.
Verdict: 1 Low
Looks good overall, with one low concern: the manager does not check whether it was cancelled after the link-fetch call returns. Prefetch is wired correctly through _start_prefetch and the has_more_rows hand-off. The kill switch keeps the old per-batch path, and the tests cover ordering, empty replies, errors, budget clamping and cancel-on-close.
A read after cancel() now fails only when the cancel left the result incomplete (links pending, more on the server, or a download it stopped). Cancelling a statement whose result had already arrived no longer turns the end of the result into an error. A link whose submit races with cancel() is kept, so it still counts as missing. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 2 Low
Looks good overall, with two low-severity notes on the downloader's new chunked read and retry loop. The download manager's prefetch, cancel-race handling (keeping links whose submit failed, keeping the link source attached after a cancel) and row-order logic hold up when traced through, and the new unit tests cover the race paths.
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Medium · 1 Low
The prefetch design looks solid, with one medium concern: an error from the mid-stream link fetch can silently drop rows when the caller retries. Cancel/close handling, the retry of interrupted body reads, and the kill switch all check out. Unit coverage is generally thorough, apart from the kill-switch forwarding noted in the second finding.
A link fetch that raises part-way through a read loses the rows that read had collected, so a retry would continue past them. The download manager now raises the same error on every later read. Test the kill switch at all six ThriftResultSet entry points. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 4 Low
Looks good overall. The prefetch, cancel and retry logic is careful and well tested, including the cancel and shutdown races. I found only low-severity issues: the meaning of has_more_rows has changed, the stored link error is re-raised as the same object, a secondary error in Cursor.cancel() can hide the original one, and body-read retries have no backoff.
Interrupted body reads wait 0.5 s, then 1 s, before retrying; a cancel ends the wait. After a failed mid-stream link fetch, later reads raise a fresh OperationalError chained to it that says to re-execute. A failure while cancelling downloads no longer hides the cancel RPC's own error. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good: one low-severity issue with how the kill switch reads _cloudfetch_prefetch=None. I checked the prefetch scheduling loop, what happens on cancel or shutdown (including the submit() racing cancel()), chunked body reads and retries in UnifiedHttpClient.request_context, and the has_more_rows handoff in ThriftResultSet. All of them hold up, and tests/unit covers them well.
Document what has_more_rows means after the handoff to the download manager. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low · 2 Nit
Looks good overall, with 3 low findings: lenient kill-switch parsing, retry backoff counted in the download-speed metrics, and a new result-set/download-manager reference cycle. The prefetch, cancel and retry logic hangs together well and has thorough unit tests, including the cancel races and the case where a failed link fetch must not let a retry skip rows.
Other findings
- ⚪ Nit —
start_timeis taken before the retry loop. When a body read is retried,download_durationtherefore also covers the failed attempt(s) and the 0.5 s / 1 s backoff waits._log_download_metricsthen reports a lower MB/s and can log theCloudFetch download slower than thresholdwarning for a file whose successful attempt was fast. To fix it, resetstart_timeat the start of each attempt, or log the retry separately. - ⚪ Nit —
set_link_source(self._fetch_more_links)creates a reference cycle: result set →resultsqueue →download_manager._link_source(a bound method) → result set. Before this change, aThriftResultSetdropped withoutclose()was freed by refcounting, and itsThreadPoolExecutor(up tomax_download_threadsidle workers) was torn down right away. Now the whole graph, including the executor and its threads, stays alive until the cyclic GC runs. Aweakref.WeakMethod(or clearing_link_sourceoncehas_moreis False, which already happens, plus on close) would avoid holding the result set strongly while a stream is still in progress.
… attempt Unrecognized string values keep prefetch on but log a warning that names the value. Download speed metrics now cover only the attempt that succeeded, not earlier failed attempts or the backoff. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
|
On the two review-body nits:
|
There was a problem hiding this comment.
Verdict: 1 Low
Looks good, with one low concern: after a failed link fetch, downloads already in flight keep running until close(). The hand-off, cancel/close, retry and kill-switch paths look correct to me. Tests cover them well, including the races with cancel and the sticky error after a failed link fetch.
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Low
Looks good, with one low concern: what the internal kill switch does with an unrecognized value. I checked the cancel and close races, the order of link fetches against the shutdown and cancel events, the body-read retry and connection release in downloader.py, the handoff of has_more_rows, and how SEA and the per-batch path share the download manager. I found no correctness problems, and the tests cover the new states well.
A kill switch should fail safe: an unparsable value falls back to per-batch fetching and logs a warning. None still means unset (prefetch on). Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 1 Medium · 2 Low
Solid change overall: prefetching, ordering, the cancel/close paths and the kill switch are carefully done and well tested. One medium concern: a failed link fetch now aborts reads of files that are already downloaded. There are also two minor robustness gaps.
| "Fetching more CloudFetch results failed earlier; the result can't " | ||
| "be read further, execute the statement again" | ||
| ) from self._link_error | ||
| if not self._cancel_requested: |
There was a problem hiding this comment.
🟡 Medium — With prefetch on, every get_next_downloaded_file call can send a FetchResults request through _schedule_downloads() before it returns the file the caller asked for. If that request fails after the backend's retries, the exception comes out of this read, even though the file the caller needs (and possibly up to max_download_threads - 1 more) may already be downloaded. _link_error is also set, so every later read fails as well. Before this change, a FetchResults failure only showed up once the current batch had been fully consumed. A fetchmany/fetchone consumer could read all the rows that had already arrived. Now those rows are lost, and the failure lands inside CloudFetchQueue.next_n_rows, so the rows that call had already collected are dropped too.
The cancel path already handles this by failing only once the downloaded files run out. Consider doing the same for link errors: store the error, keep returning the tasks already in _download_tasks, and raise only when no downloaded file is left (the same branch as the _cancel_requested check). Add a test where the link source fails while downloaded files are still queued.
There was a problem hiding this comment.
I don't think this is a correctness issue, so I'm keeping the current behaviour. Once FetchResults fails after the backend's own retries, the result can't be finished on main or here, and both raise. The only difference is how many already-downloaded rows a streaming consumer reads before the error. A fetchall* caller gets no rows either way, since its collected chunks are dropped when the error propagates. A fetchmany/fetchone consumer on main reads the rest of the current batch, then fails at the next FetchResults and loses the rows that call had collected, exactly as here.
Deferring the error would mean keeping the manager running after the failure so the remaining files can be read, which undoes the round-13 fix (stop downloads nobody will read). It would also add a third deferred-failure state next to cancel and shutdown, for a path that already ends in an error. Raising at the failure, with the sticky error making every later read fail loudly, is the simpler contract: the result is unusable and the statement has to be re-executed. If we want to return more partial rows before failing, I'd do it as a separate change with its own tests.
There was a problem hiding this comment.
Fair point that a full drain ends in an error either way, and a fetchall* caller loses its rows on both branches. The case that still differs is a caller that never needs the failing batch. Take a fetchmany(n) or fetchone() that reads a few rows and then closes, or breaks out of iteration, or a LIMIT-style consumer. On main that caller never sends the failing FetchResults, so it succeeds. With prefetch, the background request can fail during a read whose file is already downloaded, so a read that would have worked now raises. The fix doesn't need to keep downloads running. You could record _link_error, stop scheduling, keep returning the tasks already in _download_tasks, and raise in the same "nothing left" branch the cancel path uses. That keeps round-13's "stop downloads nobody will read" and only delays the error until the caller actually needs rows that can't be fetched. If you'd still rather do this in a follow-up, that's your call. I'm leaving the thread open so the regression for partial readers stays visible.
Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
There was a problem hiding this comment.
Verdict: 2 Low
Looks good overall: the prefetch hand-off, the cancel and close semantics, and the chunked body read with retries are carefully designed and well tested. Two low findings: the first link prefetch waits until the first file has downloaded and the caller reads again, and the hot-loop case (no downloads in flight, server keeps returning no links) has no test.
| # Initialize results queue if not provided | ||
| if not self.results: | ||
| self._fill_results_buffer() | ||
| else: |
There was a problem hiding this comment.
🔵 Low — _start_prefetch() only runs after the queue has been built, and ThriftCloudFetchQueue.__init__ already calls _create_next_table(). That call goes through get_next_downloaded_file → _schedule_downloads while _link_source is still None, and then blocks on task.result() for the first file. So the first prefetching FetchResults can't start until the first file has finished downloading and the caller makes its next read. If the initial batch has fewer links than max_download_threads (for example the direct-results batch), the free slots sit idle for the whole first download, which is exactly the gap this PR is meant to close.
This costs some performance but doesn't affect correctness. Possible fixes: pass the link source into the queue/manager before the first table is built, or have set_link_source call _schedule_downloads() right away. The second runs on the consumer thread, so it keeps the 'no new concurrency on the Thrift client' property. If you keep the current ordering on purpose, a comment would help.
| self._link_source = None | ||
| for link in links: | ||
| self.add_link(link) | ||
| self._submit_pending_links() |
There was a problem hiding this comment.
🔵 Low — The break that avoids polling the server only fires when self._download_tasks is non-empty. If every download slot is empty and the server keeps replying ([], True), or with only zero-row links, the while loop calls _link_source back-to-back with no backoff. That case isn't tested. The new tests (test_empty_reply_is_retried_on_the_next_call_not_in_a_loop, test_reply_with_only_empty_links_is_retried_on_the_next_call) always have a download in flight. The old per-batch fetchall loop behaves the same way, so this probably isn't a regression. Still, a test that pins the empty-tasks case would document the intent, and it would also show whether the comment ('ask again on the next call instead of polling the server now') is meant to cover this case.
What type of PR is this?
Description
With #1030, every FetchResults batch holds about one CloudFetch link per download thread, but each batch still has to finish before the next one starts. The next FetchResults goes out only after the last file of the current batch is consumed, so the slowest file holds everything up, and a new download manager and thread pool are built for each batch. Download timelines on a 4.9 GB result show an average of 5.3 of 10 downloads in flight.
The download manager can now pull links itself. Once CloudFetch starts,
ThriftResultSethands the rest of the result to the queue's download manager. Whenever a download slot is free and no links are pending, the manager asks for more links: one per free slot plus half a pool queued ahead ((free + max_download_threads // 2) × largest link, clamped to 1–4×buffer_size_bytesas in #1030). The request runs on the consumer thread, as FetchResults does today, so there's no new concurrency on the Thrift client. One thread pool now serves the whole result, and files are still returned in row order (same offset check as before). A variant that refilled only when half the slots were free kept the average at 5.3 in flight and gave no consistent gain, so slots are refilled one at a time.Bounds and trade-offs:
max_download_threads, the existing bound. The pool now stays at that bound more of the time, so a slow consumer's peak RSS is higher: +5% to +16% across three runs on 1.6 GB (latest pair 831 → 918 MB).max_download_threads // 2links (5 by default) wait for a free slot. They start once that many more files are consumed. A link expires 900 s after it is issued, so a consumer has to spend more than about 180 s per ~20 MB file before a queued link expires. With a slow consumer (0.3 s per 100k rows) the oldest link still had 898 s left when its download started (perf(thrift): request about one CloudFetch file per download thread #1030: 899.5 s). Thrift still has no link refresh.shutdown(wait=False, cancel_futures=True). Downloads now read the body in 1 MiB chunks (preload_content=False) and check the event before the GET and between chunks, so a download in flight stops within about 1 MiB and its connection is closed rather than returned to the pool. A fully read body releases its connection for reuse. urllib3 retried a failed body read only while it read the body itself, so a GET interrupted mid-body is now retried here (3 attempts).Cursor.cancel()also cancels the active result set's downloads. Later reads then raiseOperationalErrorinstead of returning a result that ends early; main raisedRequestError, a subclass, at the next FetchResults. The cancellation is raised outside the HTTP client so it isn't logged as an HTTP error.downloader.pystays clear of the lines perf(cloudfetch): cut peak memory of large CloudFetch results #1032 edits, and perf(cloudfetch): cut peak memory of large CloudFetch results #1032 applies cleanly on top. The one adjustment needed after both merge is that perf(cloudfetch): cut peak memory of large CloudFetch results #1032'stest_run_returns_arrow_buffer_for_lz4_filemock needs astream(), a one-line change._cloudfetch_prefetch=Falserestores perf(thrift): request about one CloudFetch file per download thread #1030's per-batch fetching exactly (verified live: same batches andmaxBytes). Cancel-on-close stays on either way. It isn't documented publicly.Live A/B against a PRO warehouse (Thrift; fresh processes, #1030 and this PR interleaved, warm-up discarded; fetch = time after
execute()returns):fetchall_arrow, n=5fetchall_arrow, n=5fetchmany_arrow(1M), n=3The 4.9 GB p95 comes from one 16.4 s run; the other four were 9.4–10.8 s. An earlier run of the same A/B, before the chunked reads, measured 13.96 → 9.63 s p50 and 18.06 → 10.63 s p95. Chunked reads keep connections in reuse: draining 1.6 GB (85 files) opened 6 new HTTPS connections on both #1030 and this PR.
Early close after 500k rows of the 1.6 GB result (n=3 medians):
close()cancel()1.5 s intofetchall_arrowmade the fetch fail 0.29 / 0.44 / 0.19 s later on main / #1030 / this PR. This PR raisesOperationalError, orRequestErrorwhen the next FetchResults fails first.With #1032 applied on top (locally, n=3, 1.6 GB): peak RSS 1312 → 642 MB for
fetchmany_arrow(1M)and 2376 → 1860 MB forfetchall_arrow. Fetch time stayed within noise: 4.69 → 3.34 s and 3.56 → 5.71 s, the latter with one 10 s outlier; an earlier run measured 3.96 → 3.13 s.How is this tested?
New unit tests in
test_cloudfetch_prefetch.pycover the refill rule: links are requested only when a slot is free and nothing is pending, there are never more thanmax_download_threadstasks, and row order is kept across refills. They also check that an empty reply doesn't cause a busy loop, the link budget and its clamp, the offset check, the handoff, and the kill switch. On the cancel side they cover:cancel()during a link fetch, and a scheduled download cancelled before it starts;Cursor.cancel()cancelling the active result set's downloads. perf(thrift): request about one CloudFetch file per download thread #1030's per-batch tests now run with the kill switch. The full unit suite passes, and perf(cloudfetch): cut peak memory of large CloudFetch results #1032's tests pass with both PRs applied. Against a live warehouse, thetest_driver.pye2e tests for large wide/narrow results, async large results,fetchone/fetchall/fetchmany/iteration, Arrow fetches, row limits and cancel pass (31 passed, Thrift and SEA).Manual live checks on a 30M-row, 32-link result verified exact row counts and order across batches for
fetchall_arrow,fetchmany_arrow, mixedfetchmany/fetchone, andfetchall. They also coveredclose()andcancel()mid-stream, anduse_cloud_fetch=False, with prefetch on and with the kill switch.Related Tickets & Documents
Stacked on #1030. Related: #1032 (CloudFetch decompression into private mmaps).
This pull request and its description were written by Isaac.