[analytics-engine] Fix native memory leak on Arrow C Data batch import under allocator pressure#22448
Conversation
PR Reviewer Guide 🔍(Review updated until commit 1b0cd41)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 1b0cd41 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 29cd8ce
Suggestions up to commit 83a65b1
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #22448 +/- ##
============================================
+ Coverage 73.44% 73.48% +0.03%
+ Complexity 76488 76479 -9
============================================
Files 6104 6104
Lines 346576 346578 +2
Branches 49885 49886 +1
============================================
+ Hits 254558 254676 +118
+ Misses 71736 71611 -125
- Partials 20282 20291 +9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
DatafusionResultStream and LuceneResultStream imported each native batch directly into the caller's BufferAllocator via Data.importIntoVectorSchemaRoot, which charges every imported buffer against that allocator as it walks the array. When the allocator is bounded and its limit is reached partway through a wide batch (e.g. chained-concat Utf8View columns under concurrent load), the import throws OutOfMemoryException. arrow-java's ReferenceCountedArrowArray. unsafeAssociateAllocation increments the imported C Data array's reference count *before* the throwing wrapForeignAllocation call and does not roll it back, so the reference count stays elevated, the C Data release callback never fires, and the entire native batch leaks in the producer's native allocator -- invisible to the JVM heap and the Java Arrow allocator. Under a concurrent chained-concat storm this accumulates multiple GB of native memory that never drains. Import each batch into a per-batch staging allocator that is a direct child of the unbounded root allocator, so the import can never OOM partway and the release callback always fires. The batch is returned as-is (zero-copy, no buffer transfer) and released by the existing consumer close paths, which drives the C Data reference count to zero. Staging allocators are reclaimed once drained (per import and at stream close). On import failure the staging allocator is closed, firing the native release for the whole batch. Adds DatafusionImportLeakTests, a self-contained regression that exports a multi-buffer batch across the C Data Interface and imports it back through the production staging path under a caller allocator far too small to hold it: the producer allocator (standing in for the native producer) drains to zero with the fix and is stranded on the unfixed direct-import path. A companion test pins the underlying arrow-java mid-import-OOM leak deterministically. Signed-off-by: Sandesh Kumar <sandeshkr419@gmail.com>
83a65b1 to
29cd8ce
Compare
|
Persistent review updated to latest commit 29cd8ce |
|
❕ Gradle check result for 29cd8ce: UNSTABLE Please review all flaky tests that succeeded after retry and create an issue if one does not already exist to track the flaky failure. |
| try { | ||
| if (iteratorInstance != null) { | ||
| iteratorInstance.closeLastBatch(); | ||
| iteratorInstance.reclaimDrainedStaging(); |
There was a problem hiding this comment.
this doesn't feel right - we are storing allocators for each batch and only clearing when last batch is consumed? We should be releasing each batch allocator as its read?
There was a problem hiding this comment.
Reclaim actually runs at the start of every importBatch(), not just at last-batch — each drained staging allocator is closed as soon as its batch's buffers are released (getAllocatedMemory() == 0), and the call in close() is only a final sweep. So it's incremental, not stored-till-the-end.
|
Persistent review updated to latest commit 1b0cd41 |
Description
DatafusionResultStreamandLuceneResultStreamimported each native Arrow batch directly into the caller'sBufferAllocatorviaData.importIntoVectorSchemaRoot, which charges every imported buffer against that allocator as it walks the array. When the allocator is bounded and its limit is hit part-way through a wide batch (e.g. chained-concatUtf8Viewcolumns under concurrent load), the import throwsOutOfMemoryException.arrow-java's
ReferenceCountedArrowArray.unsafeAssociateAllocationcallsretain()before the throwingwrapForeignAllocationand does not roll it back, so the imported array's reference count stays elevated, the C Data release callback never fires, and the entire native batch leaks in the producer's native allocator — invisible to the JVM heap and the Java Arrow allocator. Under a concurrent chained-concatstorm this accumulates multiple GB of native memory that never drains, eventually wedging the node into HTTP 429s until restart. It is visible only in_plugins/arrow_base/stats → memory_pools.runtime.allocated_bytes; the DataFusion pool stats and circuit breakers read 0.Fix: import each batch into a per-batch staging allocator that is a fresh unbounded child of the root allocator, so a single import can never OOM part-way through the array and the release callback always fires. The batch is returned as-is (zero-copy, no buffer transfer) and released by the existing consumer close paths, which drives the C Data reference count to zero. Staging allocators are reclaimed once drained (per import and at stream close); on import failure the staging allocator is closed immediately, firing the native release for the whole batch. The same fix is applied to both result-stream import paths.
This is independent of whether
POOL_QUERYis bounded: making the shared pool unbounded does not fix it, because many concurrent imports still pile onto the one shared pool and hit the node's overall memory ceiling, so an import still OOMs mid-batch and strands. A per-import staging allocator is what prevents the mid-array OOM. The underlying arrow-java bug is being reported upstream separately; this change works around it for the OpenSearch import paths.Testing
DatafusionImportLeakTests(new, self-contained — no native runtime): exports a multi-buffer batch across the C Data Interface and imports it back through the production staging path under a caller allocator far too small to hold it; asserts the producer allocator (standing in for the native producer) fully drains. This test fails on the unfixed direct-import path (Memory was leaked ... (8521000)) and passes with the fix, so it is a genuine regression guard. A companion test pins the arrow-java mid-import-OOM leak deterministically.DatafusionResultStreamTests.testStreamConsumeAndCloseDrainsAllocatorverifies the staging import path end-to-end against a real native stream under a small caller allocator and asserts the shared root allocator drains after consume + close.concatstorm: stock leaks ~3.1 GB that stays flat at idle forever; with the fix nativeallocated_bytesdrains from ~2.2 GB back to ~13 MB after every storm, in both bounded and unboundedPOOL_QUERYconfigurations.Check List
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.