Repository navigation
perf(thrift): prefetch CloudFetch links while earlier files download - #1038
vuanhphung wants to merge 5 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 close() on another thread while FetchResults is in flight now ends the stream instead of submitting to a shut-down pool. 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
The design is solid and well tested. I found 1 medium issue (a link-fetch error mid-read silently drops the current file's rows, despite a test saying fetching can resume) and 2 low ones (a close/submit race, and the kill switch using bool() on a value that may be a string). Nit: in downloader.py, stdlib threading sits under the TYPE_CHECKING block next to a comment that's about keeping thrift out, which reads as if it also needs to be kept out.
"false", "0", "no" and "off" now turn prefetch off, so the kill switch also works when set from a URL query. Add a queue-level test that a failed link fetch propagates out of the fetch call. Signed-off-by: Vu Anh Phung <vu.phung@databricks.com> Co-authored-by: Isaac <no-reply@databricks.com>
|
On the review-body nit ( |
There was a problem hiding this comment.
Verdict: 1 Medium · 1 Low
The prefetch design looks sound, with one medium concern: if a link fetch fails and the caller retries, rows can be skipped with no error, where the old per-batch path raised a DataError. Nit: import threading in downloader.py sits under TYPE_CHECKING, next to a comment about keeping thrift out of the import path. It works because annotations are deferred, but threading is stdlib and could be a normal import.
|
The |
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–7% in two runs here (median 840 → 891 MB on 1.6 GB) and +13% in an earlier prototype run.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.close()now sets a per-manager cancel event. Downloads check it before the GET and again before decompressing, andshutdown(wait=False, cancel_futures=True)drops queued work. Bodies already being read still finish.cancel()only cancels the statement on the server: until the next link fetch fails, up tomax_download_threads // 2already-queued links can still start downloading. Thedownloader.pychanges stay away from the decompression lines that 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._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=3Early close after 500k rows of the 1.6 GB result (n=3 medians):
close()Most of the close cost comes from #1030's larger batches; this PR adds little to it. What remains after close is the bodies already being read (the interpreter waits for them at exit). The cancel check skips their decompression, which is why RSS after close drops.
cancel()1.5 s intofetchall_arrowraisedRequestError0.20 / 0.36 / 0.27 s later on main / #1030 / this PR. This PR also started 3 downloads from queued links after the cancel, where the others started none.With #1032 applied on top (locally, n=3, 1.6 GB): peak RSS 1283 → 685 MB for
fetchmany_arrow(1M)and 2392 → 1862 MB forfetchall_arrow, with fetch time 3.85 → 3.67 s and 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, the kill switch, and cancel-on-close (shutdown sets the event and cancels queued futures; a cancelled download skips the GET or the decompression). #1030's per-batch tests now run with the kill switch. The full unit suite passes, and #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.