fix: release task-shared memory pool references via RAII guard - #5217
Closed
andygrove wants to merge 2 commits into
Closed
fix: release task-shared memory pool references via RAII guard#5217andygrove wants to merge 2 commits into
andygrove wants to merge 2 commits into
Conversation
Entries in TASK_SHARED_MEMORY_POOLS leaked whenever a plan's lifecycle did not run to completion. `create_memory_pool` incremented the refcount, but `createPlan` can still fail afterwards (local dir decoding, session context setup, the key unwrapper global ref), and on the JVM side `plan` is a field initializer evaluated before the task completion listener is registered, so a failed `createPlan` never calls `releasePlan`. `releasePlan` in turn flushed metrics before releasing the pool, so a metrics failure stranded the entry and leaked the boxed ExecutionContext. Stranded entries are never reclaimed -- keys are unique task attempt ids and nothing prunes the map -- and each one holds a JNI global ref to the task's CometTaskMemoryManager, transitively pinning its TaskMemoryManager and TaskContext. Replace the explicit release call with a `TaskSharedPoolRef` guard stored on the ExecutionContext, so the reference is released whenever the context is dropped, including on unwind. `releasePlan` now reclaims the Box up front and flushes metrics last, so the context is freed even if that fails. Because the Box is now always freed, a second `releasePlan` on the same pointer would be a use-after-free rather than a leak. `CometExecIterator.close` could do exactly that, since it set `closed` only after the teardown calls, so a throw left the iterator eligible for a second close from the task completion listener. Set `closed` first. Also collapse the three duplicated `or_insert_with` blocks in `create_memory_pool` into the new `acquire_task_shared_pool` helper, use `saturating_sub` for the refcount, and recover from mutex poisoning rather than propagating it -- a panic holding that lock would otherwise fail every subsequent plan creation in the executor.
Cleanups from a quality pass over the previous commit, no behaviour change except where noted: - Use `parking_lot::Mutex` for TASK_SHARED_MEMORY_POOLS instead of hand-rolled `PoisonError` recovery over `std::sync::Mutex`. parking_lot never poisons, is already a direct dependency, and is what the sibling THREAD_MEMORY_POOLS registry and fair_pool.rs in this same module already use. Removes the `lock_pools` helper and its rationale comment. - Delete `MemoryPoolType::is_task_shared` and the `debug_assert` that was its only remaining caller. Task-sharedness is now encoded solely by which `create_memory_pool` arms return a guard, so the predicate was a second source of truth reconciled only by an assertion that compiles out in release builds. This also drops `TaskSharedPoolRef::pool_type`, which existed only to name the pool type in one warning, and with it the `Debug` derive on MemoryPoolType. - Collapse the four repeated destructure-and-rewrap task-shared arms of `create_memory_pool` behind a local `task_shared` helper paired with the existing `tracked` helper, so `Some(pool_ref)` appears once. - Use the `Entry` API in `TaskSharedPoolRef::drop` so the release path hashes the key once rather than for both `get_mut` and `remove`. - Drop the raw-pointer round trip through `get_execution_context`'s `&mut` in `releasePlan`; cast straight to `*mut ExecutionContext` for `Box::from_raw`. - State the concrete consequence of the `_task_shared_pool_ref` field ordering (a later plan in the same task would get a second full-budget pool) rather than just asserting that the ordering matters. Behaviour change: `CometExecIterator.close` now releases the native plan even when an earlier teardown step throws. Setting `closed` first removed the double-release hazard but made any throw from `currentBatch.close()`, `nativeUtil.close()` or a shuffle block iterator skip `releasePlan` entirely, which strands the very context this change set out to free. The teardown failure is still propagated, with a release failure attached as a suppressed exception.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which issue does this PR close?
Part of #5212 (positions 2, 3 and 8).
Rationale for this change
Entries in
TASK_SHARED_MEMORY_POOLSleak whenever a plan's lifecycle does not run to completion, and a stranded entry is never reclaimed: keys are unique task attempt ids and nothing prunes the map. Each entry holds anArc<Global<JObject>>for the task'sCometTaskMemoryManager, which transitively pins itsTaskMemoryManagerandTaskContext, so this accumulates JVM objects for the lifetime of the executor.There were two leak paths.
createPlanfailing after the pool was acquired.create_memory_poolincrements the refcount, butcreatePlancan still fail afterwards —local_dirsdecoding,prepare_datafusion_session_context, and the key-unwrapper global ref all use?. On the JVM sideplanis a field initializer (CometExecIterator.scala:87) evaluated before the task-completion listener is registered (:139), so whencreatePlanthrows there is noreleasePlanand no listener — the refcount is never decremented.releasePlanfailing before the release. It ranupdate_metrics(env, execution_context)?beforehandle_task_shared_pool_release, so a metrics failure stranded the entry and skipped theBox::from_rawthat frees the context.What changes are included in this PR?
Tie the release to a guard rather than an explicit call.
create_memory_poolnow returns aTaskSharedPoolRefalongside the pool, stored on theExecutionContext. Dropping the context releases the reference, so both paths above are covered by construction:createPlanunwinding drops the guard, andreleasePlandropping the box drops the guard.The guard is declared as the last field on
ExecutionContextso it drops aftersession_ctxand theroot_op/streamthat hold reservations. If it dropped first, the pool would leave the map while still in use and the next plan in the same task attempt would build a second full-budget pool, letting the task exceed its per-task limit. The field comment says so.releasePlanreclaims theBoxup front and flushes metrics last, so the context is freed even when the metrics update fails.CometExecIterator.closesetsclosedfirst and releases the native plan unconditionally. This is position 8 in the epic, and it has to land with this change rather than separately: now that theBoxis always freed, a secondreleasePlanon the same pointer is a use-after-free rather than a leak, andclose()previously setclosed = trueonly afterreleasePlanandtraceMemoryUsage().Setting
closedfirst alone would have traded that double-release for a guaranteed leak — a throw fromcurrentBatch.close(),nativeUtil.close()or a shuffle block iterator would skipreleasePlanentirely with no retry, stranding the very context this PR frees. So the release now runs even when teardown fails, with the teardown exception propagated and any release failure attached as a suppressed exception.Deletions that fall out of the above:
MemoryPoolType::is_task_sharedand thedebug_assertthat was its only remaining caller. Task-sharedness is now encoded solely by whichcreate_memory_poolarms return a guard, so the predicate had become a second source of truth reconciled only by an assertion that compiles out in release builds.config.rsis now a net deletion.ExecutionContext::task_attempt_idandmemory_pool_config— both existed only to feed the old explicit release call.Smaller items: the duplicated task-shared arms of
create_memory_poolcollapse behind anacquire_task_shared_poolhelper (which is also what makes the refcounting unit testable, since it takes the pool factory as a closure);TASK_SHARED_MEMORY_POOLSmoves toparking_lot::Mutex, matchingfair_pool.rsin the same module and the siblingTHREAD_MEMORY_POOLSregistry, which removes the need for poison handling entirely; the refcount usessaturating_sub; and the release path uses theEntryAPI so it hashes the key once.How are these changes tested?
Five new unit tests in
task_shared.rscovering the refcount lifecycle: plans in the same task share one pool, plans in different tasks do not, the pool survives until the last plan releases it, dropping the guard alone releases the pool (thecreatePlan-unwind case), and releasing an already-removed entry does not panic.Full native crate suite passes (140 passed, 4 ignored).
cargo clippy --all-targets -- -D warningsclean, as arespotless:checkandscalastyle:check.JVM suites exercising plan create/release against the default on-heap
greedy_task_sharedpool:CometAggregateSuite— 88 succeeded, 0 failedCometNativeShuffleSuite— 27 succeeded, 0 failedWhat is not directly tested: the leak paths themselves. Both require injecting a failure into
createPlanorupdate_metrics, which there is no hook for, so the unit test for the unwind case exercises the guard in isolation rather than throughcreatePlan. Likewise the newclose()behaviour is not covered by a test that makesnativeUtil.close()throw. The guard makes the release unconditional at the type level, which is the property I would want a test to establish, but it is verified by construction and inspection rather than by a test that reproduces the leak.I also have not measured the leak's magnitude on a real workload — it requires plan-creation or metrics failures, which are not the normal path. The severity comes from entries never being reclaimed once stranded, not from a high rate of stranding.
Are there any user-facing changes?
No API or configuration changes. Long-running executors that hit
createPlanor metrics failures will no longer accumulate memory pool entries and pinned JVM task objects.Deliberately left for follow-up
THREAD_MEMORY_POOLShas the same leak and is not converted here.register_memory_pool(jni_api.rs:494) registers a(thread_id, context_id)entry holding anArc<dyn MemoryPool>, and thejni_new_global_ref!fortask_contexta few lines later can still fail before theExecutionContextexists — stranding an entry in a map nothing prunes, exactly as described above. It only affects runs with tracing enabled, and converting it is a second guard rather than a change to this one, so I have kept it out to keep this diff reviewable and will add it to #5212 as its own item. Flagging it explicitly rather than leaving the inconsistency silent: after this PR, one of the two per-plan global registries is RAII-managed and the other is not.Sibling close paths now differ in discipline.
ArrowReaderIteratorandNativeBatchDecoderIteratorset their closed flag last, andNativeBaseusescompareAndSet. OnlyCometExecIteratorowns a native context whose double-free matters, so I have not touched the others, but converging on one idiom would be worthwhile.