[GLUTEN-12716][CORE] Make AppendableSpillerList safe to append during a spill - #12717
Merged
jackylee-ch merged 1 commit intoAug 11, 2026
Merged
Conversation
… a spill Spillers.AppendableSpillerList holds a plain ArrayList that append mutates and spill iterates, with no lock on either side. The two run on different threads. NativeMemoryManager hands the list to ReservationListeners, which registers it as a node of the task's memory tree, before anything is appended to it; the shuffle writers then append their own spiller on the first non-empty batch, which is well after the upstream pipeline started running. On the other side TreeMemoryTargets#spillTree walks every consumer of the task, by design - "Spill from root node so other consumers also get spilled" - so a spill triggered by any consumer reaches every other consumer's list, across runtime boundaries. The triggering thread need not be the task thread: an allocation on a Velox io thread goes through that runtime's ReservationListener, so an async split prefetch can fail to reserve and start a root-level walk while the task thread is back in Java appending. (Those threads carry the parent task's TaskContext, but nothing on the reserve-to-spill path reads it; the listener is what makes the walk reachable.) The walk also stays inside the loop across a JNI shrink or reclaim call, so the window is milliseconds, not one instruction. When the append is observed mid-walk, the CME is certain rather than a narrow instruction-window race: the append raises size, so hasNext stays true, so next() runs, and its first act is the modCount check. Whether it is observed is not guaranteed - neither size nor modCount is volatile and add is unsynchronized - and an unobserved append is simply skipped. SpillersTest reproduces the CME deterministically because its latches supply the happens-before edge that production lacks. What this fixes is the CME and the unpublished read. It does not change which spillers a walk in flight covers: a copy-on-write iterator is a snapshot, so an append landing mid-walk still misses that round, which SpillersTest asserts. That is covered a level up: ThrowOnOomMemoryTarget.borrow retries the reservation up to 9 times, and each retry re-enters spillTree and takes a fresh snapshot that includes the appended spiller, so the skip defers one round of reclaim rather than losing it. Snapshot semantics are a choice worth naming. CopyOnWriteArrayList#size and #get both read the current array, so an index walk would be equally thread-safe and would pick up mid-walk appends, closing that gap. It would also let a spiller that appends during its own spill extend a single walk without bound. Snapshot iteration fixes the work per round instead and leans on the retry above. GLUTEN-11509 was this race one field over, on TreeMemoryConsumer#children, and its fix (apache#11553) switched that map to ConcurrentHashMap and left this list alone. Its report noted the main branch had no asynchronous use of the tree yet; the per-runtime hooked executor for Velox's io threads (apache#11882, apache#12302) and the Delta native stats writer (apache#11419) both supply one now. NativeMemoryManager's mutableStats map has the same publish-then-mutate shape and is still unguarded, tracked separately - it is read only by Node#stats() on the OOM-message path, never by spillTree, so it does not affect reclaim. Use CopyOnWriteArrayList, declared as the concrete type so a revert to ArrayList is a compile error, and mark the class @threadsafe since MemoryTarget documents the opposite default. Appends are two per list per task while walks are on the reclaim path, so the copy is cheap; iteration over a snapshot also stays correct if a spiller ever appends during its own spill, which locking append would not cover.
|
Run Gluten Clickhouse CI on x86 |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a concurrency hazard in gluten-core’s memory spilling infrastructure by making Spillers.AppendableSpillerList safe when append() can race with a concurrent spill walk (potentially from non-task threads), preventing ConcurrentModificationException and missed reclaim work.
Changes:
- Switch
AppendableSpillerList’s internal storage fromArrayListtoCopyOnWriteArrayListto enable snapshot-safe iteration during spills. - Mark
AppendableSpillerListas@ThreadSafeto document and enforce its intended concurrency contract. - Add focused concurrency unit tests (
SpillersTest) that deterministically reproduce append-during-spill and self-append-during-spill scenarios.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| gluten-core/src/main/java/org/apache/gluten/memory/memtarget/Spillers.java | Makes AppendableSpillerList spill iteration safe under concurrent appends by using CopyOnWriteArrayList and documenting thread-safety. |
| gluten-core/src/test/java/org/apache/gluten/memory/memtarget/SpillersTest.java | Adds deterministic tests covering cross-thread append during spill, re-entrant append during own spill, and spill short-circuit behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Contributor
Author
|
cc @jackylee-ch |
Contributor
Author
|
Thank you @jackylee-ch |
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.
What changes were proposed in this pull request?
Spillers.AppendableSpillerListholds a plainArrayListthatappendmutates andspilliterates, with no lock on either side, and the two run on different threads.NativeMemoryManagerhands the list toReservationListeners, which registers it as a node of the task's memory tree, before anything is appended to it. The shuffle writers then append their own spiller on the first non-empty batch, which is well afterrecords.next()started driving the upstream pipeline. On the other sideTreeMemoryTargets#spillTreewalks every consumer of the task by design, per the comment at its root-level caller inMemoryTargets: "Spill from root node so other consumers also get spilled". So a spill triggered by any consumer reaches every other consumer's list, across runtime boundaries. The triggering thread need not be the task thread: an allocation on a Velox io thread goes through that runtime'sReservationListener, so an async split prefetch can fail to reserve and start a root-level walk while the task thread is back in Java appending. The walk also stays inside the loop across a JNI shrink or reclaim call, so the window is milliseconds rather than one instruction.Five call sites append to such a list, and they are not equally exposed.
NativeMemoryManager.scala:59andNativePlanEvaluator.java:94append while theirNativeMemoryManageris still being constructed, when no other thread of the task is allocating yet. The three shuffle writers (ColumnarShuffleWriter.scala:194, and the Celeborn and Uniffle variants) are the reachable ones, because their list is registered when the writer is built and appended to only on the first non-empty batch. The fix is ingluten-core, so it covers all five regardless.This switches the field to
CopyOnWriteArrayList. The two sides cannot be brought under one lock cheaply, iteration is held open across a JNI spill, and appends are two per list per task while walks are on the reclaim path, so the copy is cheap. Snapshot iteration also stays correct if a spiller ever appends during its own spill, which lockingappendwould not cover. The field is declared as the concrete type so a revert toArrayListis a compile error, and the class is marked@ThreadSafebecauseMemoryTargetdocuments the opposite default for its implementations.Worth naming what this does not change: a copy-on-write iterator is a snapshot, so an append landing mid-walk still misses that round, and
SpillersTestasserts exactly that.ThrowOnOomMemoryTarget.borrowretries the reservation and each retry re-entersspillTreeon a fresh snapshot, so the skip defers one round of reclaim rather than losing it. An index walk over the same list would be equally thread-safe and would pick up mid-walk appends, but it would also let a self-appending spiller extend a single walk without bound; snapshot iteration fixes the work per round instead.GLUTEN-11509 was this race one field over, on
TreeMemoryConsumer#children, with a production stack trace from the Delta statistics writer thread. Its fix (#11553) switched that map toConcurrentHashMapand left this list alone. That issue noted the main branch had no asynchronous use of the memory tree yet; the per-runtime hooked executor for Velox io threads (#11882, #12302) and the Delta native statistics writer (#11419) both supply one now.NativeMemoryManager'smutableStatsmap has the same publish-then-mutate shape and is still unguarded; it is read only byNode#stats()on the OOM-message path, never byspillTree, so it does not affect reclaim and is tracked separately.How was this patch tested?
New
SpillersTestwith three tests.testAppendFromAnotherThreadDuringSpilluses two latches to pin the interleaving deterministically: the appending thread runs while the spilling thread sits between two entries. It then asserts the appended spiller was not lost by running a second walk, and asserts the first walk visited the three registered entries in order.testAppendDuringOwnSpillcovers a spiller that appends during its own spill, the case a lock aroundappendwould not cover.testSpillStopsOnceTheRequestIsMetpins the pre-existing short-circuit and is a boundary assertion, not a guardrail for this change.Each assertion was checked against a mutant, restoring the source after every run:
CopyOnWriteArrayListback toArrayListConcurrentModificationExceptionappendsilently drops once the list holds three (keeps COW, no CME)expected:<1> but was:<0>expected:<[first, blocking, third]> but was:<[third, blocking, first]>mvn -Pspark-3.5 -pl gluten-core testgives 33 Java and 39 Scala tests passing. Cross-versiontest-compilepasses on spark-3.3, spark-3.4, spark-4.0 with scala-2.13, and spark-4.1 with scala-2.13.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code(Opus 5)
Closes #12716