Stream CloudFetch chunk decompression to bound memory and prevent OOM on large results#1509
Conversation
8e41b07 to
7c78368
Compare
8fca624 to
7c29f02
Compare
| && totalChunksInMemory < allowedChunksInMemory) { | ||
| while (!isClosed && nextChunkToDownload < chunkCount) { | ||
| ArrowResultChunkV2 chunk = chunkIndexToChunksMap.get(nextChunkToDownload); | ||
| if (!canScheduleChunkDownload(chunk.getChunkSizeInBytes())) { |
There was a problem hiding this comment.
will it get attempted again?
There was a problem hiding this comment.
Yes. When canScheduleChunkDownload(...) returns false we break without advancing nextChunkToDownload, so the chunk is deferred, not dropped. downloadNextChunks() is re-invoked from releaseChunk() every time a consumed chunk frees budget, and it retries the same nextChunkToDownload. The always-allow-one rule (totalChunksInMemory == 0) guarantees forward progress even if a single chunk is larger than the whole budget. I've added a comment at the break documenting this.
| * Returns a stream that decompresses {@code compressedInput} lazily as it is read, so the full | ||
| * decompressed payload is never materialized alongside the compressed bytes. | ||
| */ | ||
| public static InputStream decompressToInputStream( |
There was a problem hiding this comment.
There was a problem hiding this comment.
Good catch — #1470's decompressLazy and this decompressToInputStream are the same idea: wrap the compressed bytes in LZ4FrameInputStream and let the Arrow reader pull decompressed bytes lazily instead of materializing the full payload. Since #1470 is still open (and stacked on #1468), I didn't want to introduce a merge dependency here. Proposal: whichever lands first, the other consolidates onto the single shared helper — they're behaviourally identical so the merge is trivial. Happy to be the one to converge.
(Side note from testing: LZ4FrameInputStream already reads concatenated LZ4 frames on demand, so this also covers the >2 GB-decompressed case without a custom multi-frame reader.)
|
In-memory byte budget is silently disabled whenever chunk sizes are unknown (0). chunkSizeInBytes defaults to 0 when the SEA manifest reports a null getByteCount() (ArrowResultChunk.java:222-223) or when the Thrift getBytesNum() is unset (line 256). With 0-sized chunks, totalBytesInMemory never grows, so canScheduleChunkDownload (AbstractRemoteChunkProvider.java:331-334) only ever enforces the count limit and the byte budget this PR adds is inactive. On exactly the large-result workloads where byte sizes may be missing, the heap bound silently does not apply, reintroducing the OOM the PR targets. |
|
Mostly LG, some minor comments |
Addresses PR #1509 review: the in-memory byte budget was silently inert when the result manifest did not report chunk sizes. When SEA's getByteCount() is null or Thrift's getBytesNum() is unset, getChunkSizeInBytes() returns 0, so totalBytesInMemory never grew and canScheduleChunkDownload only enforced the parallel-count limit -- disabling the OOM protection on exactly the large-result workloads it targets. Charge size-less chunks a conservative per-chunk estimate (UNKNOWN_CHUNK_SIZE_ESTIMATE_BYTES) via effectiveChunkSizeInBytes(), applied consistently when reserving budget (both RemoteChunkProvider and RemoteChunkProviderV2) and releasing it (AbstractRemoteChunkProvider). Also document that a chunk deferred for lack of budget is retried from releaseChunk() once a consumed chunk frees space. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
|
Fixed. You're right: with I added One honest caveat worth flagging: the budget charges the manifest's (compressed) |
|
📄 src/main/java/com/databricks/jdbc/common/util/DecompressionUtil.java 💡 [MINOR] Possibly unused decompressToStream after switch to decompressToInputStream Potentially dead method — This PR replaces the ArrowResultChunk call site from DecompressionUtil.decompressToStream(...) (removed line 111 in ArrowResultChunk.java) to the new DecompressionUtil.decompressToInputStream(...). If decompressToStream had no other callers, it is now unused. 📄 src/main/java/com/databricks/jdbc/api/impl/arrow/StreamingChunkProvider.java
PR's byte-budget bound is missing from StreamingChunkProvider. The PR description states the in-memory byte budget applies to both RemoteChunkProvider and StreamingChunkProvider, but StreamingChunkProvider (StreamingChunkProvider.java:48) implements ChunkProvider directly and does not extend AbstractRemoteChunkProvider, so it inherits none of the new gating (maxBytesInMemory, canScheduleChunkDownload, totalBytesInMemory). Its triggerDownloads() (lines 504-530) schedules downloads bounded only by the chunk count maxChunksInMemory (line 509). As a result, when this provider is selected (constructed in ArrowStreamResult.java:121/202), up to maxChunksInMemory large chunks can be downloaded and decompressed concurrently with no byte cap — the exact heap-exhaustion (OOM) scenario the PR set out to fix remains open for this path, and the PR description overstates coverage. 📄 src/main/java/com/databricks/jdbc/api/impl/arrow/incubator/RemoteChunkProviderV2.java 💡 [MINOR] In-memory budget charged before throwing link fetch in RemoteChunkProviderV2.downloadNextChunks leaks on failure Byte/count budget charged before a throwing link fetch, leaked on error. In RemoteChunkProviderV2.downloadNextChunks(), totalChunksInMemory++ and the PR-added totalBytesInMemory += chunkSizeInBytes (lines 123-124) run before the linkDownloadService.getLinkForChunk(...).get() block (lines 125-142). That block re-throws DatabricksSQLException on ExecutionException/InterruptedException, exiting downloadNextChunks() before nextChunkToDownload++ (line 144). The reserved count and bytes are never reconciled on this path — a link-fetch failure leaves the budget over-charged for a chunk that was never scheduled. Unlike RemoteChunkProvider (lines 128-132), which increments only after a successful submit(), V2 reserves budget before the fallible operation. If scheduling continues after such a failure, the inflated totals can throttle or block further downloads. |
13bb63e to
247c232
Compare
|
Heads-up on a scope change (force-pushed): I've removed the in-memory byte-budget ( Why: real-warehouse testing showed the byte budget was redundant with the existing This makes the two earlier review comments obsolete:
Verified byte-identical results (SEA/Thrift, LZ4/NONE, complex types, multiple heaps) vs unpatched and the 2.7.5/2.8.1 Simba drivers. |
…vent OOM Fixes #1508. On the 3.x driver, downloading a large CloudFetch result could exhaust the JVM heap (OutOfMemoryError, surfaced as "Failed to ready chunk" / "Download failed for chunk index 0") where the 2.x driver succeeded at the same heap. Root cause: each chunk was decompressed by materializing the ENTIRE decompressed Arrow payload into an on-heap byte[] (several times the compressed size) before parsing. With up to cloudFetchThreadPoolSize (default 16) chunks decompressing in parallel, on-heap usage scaled with download concurrency rather than heap. Fix: decompression is now streamed straight into the Arrow reader via DecompressionUtil.decompressToInputStream (a lazy LZ4FrameInputStream), so the full decompressed payload is never materialized on-heap alongside the compressed bytes. Peak per-chunk heap drops from ~compressed+decompressed to ~compressed plus one LZ4 block. The parsed Arrow vectors are off-heap and unchanged, so results are byte-identical (verified: cell checksums match the unpatched driver and the 2.7.5/2.8.1 Simba drivers across heap sizes). For heaps too small even with streaming, lower the existing cloudFetchThreadPoolSize connection property to reduce the number of chunks buffered concurrently (verified: the repro streams at -Xmx48m with cloudFetchThreadPoolSize=1). NO_CHANGELOG=true Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
247c232 to
9ddfef2
Compare
Summary
Fixes #1508.
Downloading a large query result via CloudFetch on the 3.x driver could exhaust the JVM heap and throw
java.lang.OutOfMemoryError: Java heap space(surfaced asFailed to ready chunk/Download failed for chunk index 0), where the 2.x driver succeeded at the same heap. This was a 2.x → 3.x regression.Root cause
The
OutOfMemoryErroris on-heap. Each chunk was decompressed by materializing the entire decompressed Arrow payload into an on-heapbyte[](several times the compressed size) before parsing. With up tocloudFetchThreadPoolSize(default 16) chunks decompressing in parallel, transient on-heap usage scaled with download concurrency rather than with available heap.(The parsed Arrow vectors themselves are off-heap, so they were not the cause — the transient on-heap decompression buffers were.)
Fix
Streamed decompression. Decompression is now streamed directly into the Arrow reader via
DecompressionUtil.decompressToInputStream(a lazyLZ4FrameInputStream), so the decompressed payload is never materialized on-heap alongside the compressed bytes. Peak per-chunk on-heap cost drops fromcompressed + full-decompressedtocompressed + one LZ4 block. Chunks are still downloaded, decompressed, and parsed in parallel ahead of consumption — throughput is unaffected.This is the same
LZ4FrameInputStreamthe 2.x Simba driver feeds its decompression through; this change simply pipes it into Arrow instead of buffering the output.For constrained heaps
If a workload still hits OOM on a very small heap (many concurrent chunks × per-chunk cost), lower the existing
cloudFetchThreadPoolSizeconnection property (default 16) to reduce the number of chunks buffered concurrently — this is the memory-vs-throughput lever. No new connection property is introduced.Scope note
An earlier revision of this PR also added an in-memory byte-budget scheduler (
cloudFetchMaxBytesInMemory). It was removed after testing showed it was redundant with the existingcloudFetchThreadPoolSizelimit and its heuristic default did not reliably bind (the manifest reports compressed size, while the OOM is driven by decompressed + parse transients). Streaming decompression is the robust fix; concurrency is already tunable viacloudFetchThreadPoolSize.Testing
Verified against a SQL warehouse on the issue's 26-column, 169,769-row repro table:
-Xmx128m; the patched driver streams the full result at-Xmx128m(peak ~99 MB), and down to-Xmx48mwithcloudFetchThreadPoolSize=1(peak ~42 MB) — below where the 2.7.5 / 2.8.1 Simba drivers OOM.DecompressionUtilTest; thejdbc-coreunit suite passes.NO_CHANGELOG=true
This pull request and its description were written by Isaac.