This is an automated email from the ASF dual-hosted git repository.

jackylee-ch pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 413901f9d4 [GLUTEN-12716][CORE] Make AppendableSpillerList safe to 
append during a spill (#12717)
413901f9d4 is described below

commit 413901f9d4c581830a498cf46b222ec6d622b366
Author: YangJie <[email protected]>
AuthorDate: Tue Aug 11 09:32:00 2026 +0800

    [GLUTEN-12716][CORE] Make AppendableSpillerList safe to append during a 
spill (#12717)
---
 .../apache/gluten/memory/memtarget/Spillers.java   |  10 +-
 .../gluten/memory/memtarget/SpillersTest.java      | 178 +++++++++++++++++++++
 2 files changed, 187 insertions(+), 1 deletion(-)

diff --git 
a/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/Spillers.java 
b/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/Spillers.java
index 220a9de32b..970a500050 100644
--- a/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/Spillers.java
+++ b/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/Spillers.java
@@ -16,7 +16,10 @@
  */
 package org.apache.gluten.memory.memtarget;
 
+import javax.annotation.concurrent.ThreadSafe;
+
 import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
 
 public final class Spillers {
   private Spillers() {
@@ -61,8 +64,13 @@ public final class Spillers {
     }
   }
 
+  @ThreadSafe
   public static class AppendableSpillerList implements Spiller {
-    private final List<Spiller> spillers = new ArrayList<>();
+    // Callers keep appending after the list is registered with the task's 
memory tree, and the walk
+    // below runs on whichever thread hit the memory limit, holding the 
iteration open across a JNI
+    // spill. So an append can land mid-walk, and the two sides share no lock. 
Copy-on-write also
+    // stays correct if a spiller ever appends during its own spill, which a 
lock would not cover.
+    private final CopyOnWriteArrayList<Spiller> spillers = new 
CopyOnWriteArrayList<>();
 
     private AppendableSpillerList() {}
 
diff --git 
a/gluten-core/src/test/java/org/apache/gluten/memory/memtarget/SpillersTest.java
 
b/gluten-core/src/test/java/org/apache/gluten/memory/memtarget/SpillersTest.java
new file mode 100644
index 0000000000..894f76333c
--- /dev/null
+++ 
b/gluten-core/src/test/java/org/apache/gluten/memory/memtarget/SpillersTest.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.gluten.memory.memtarget;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class SpillersTest {
+
+  private static Spiller countingSpiller(AtomicInteger counter) {
+    return new Spiller() {
+      @Override
+      public long spill(MemoryTarget self, Phase phase, long size) {
+        counter.incrementAndGet();
+        // Reclaim nothing, so the caller walks the rest of the list.
+        return 0L;
+      }
+    };
+  }
+
+  private static Spiller recordingSpiller(String name, List<String> order, 
AtomicInteger counter) {
+    return new Spiller() {
+      @Override
+      public long spill(MemoryTarget self, Phase phase, long size) {
+        order.add(name);
+        counter.incrementAndGet();
+        return 0L;
+      }
+    };
+  }
+
+  @Test
+  public void testAppendFromAnotherThreadDuringSpill() throws Exception {
+    // Callers append after the list is registered with the task's memory 
tree, and a spill runs on
+    // whichever thread hit the limit. The latches pin that interleaving: the 
appending thread runs
+    // while the spilling thread sits between two entries.
+    final Spillers.AppendableSpillerList spillers = Spillers.appendable();
+    final MemoryTarget target = new NoopMemoryTarget();
+    final AtomicInteger spillCount = new AtomicInteger(0);
+    final AtomicInteger appendedSpills = new AtomicInteger(0);
+    final List<String> order = new CopyOnWriteArrayList<>();
+    final CountDownLatch reachedMiddle = new CountDownLatch(1);
+    final CountDownLatch appended = new CountDownLatch(1);
+    final AtomicReference<Throwable> appendFailure = new AtomicReference<>();
+
+    spillers.append(recordingSpiller("first", order, spillCount));
+    spillers.append(
+        new Spiller() {
+          @Override
+          public long spill(MemoryTarget self, Phase phase, long size) {
+            order.add("blocking");
+            spillCount.incrementAndGet();
+            reachedMiddle.countDown();
+            try {
+              Assert.assertTrue(
+                  "appending thread did not finish within 30s",
+                  appended.await(30, TimeUnit.SECONDS));
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+              throw new IllegalStateException(e);
+            }
+            return 0L;
+          }
+        });
+    spillers.append(recordingSpiller("third", order, spillCount));
+
+    final Thread appender =
+        new Thread(
+            () -> {
+              try {
+                Assert.assertTrue(
+                    "spilling thread did not reach the middle spiller within 
30s",
+                    reachedMiddle.await(30, TimeUnit.SECONDS));
+                spillers.append(countingSpiller(appendedSpills));
+              } catch (Throwable t) {
+                appendFailure.compareAndSet(null, t);
+              } finally {
+                appended.countDown();
+              }
+            },
+            "spiller-appender");
+    appender.setDaemon(true);
+    appender.start();
+    final List<String> firstWalk;
+    try {
+      Assert.assertEquals(0, spillers.spill(target, Spiller.Phase.SPILL, 100));
+    } finally {
+      // Snapshot before the second walk records into the same list.
+      firstWalk = Arrays.asList(order.toArray(new String[0]));
+      appender.join(TimeUnit.SECONDS.toMillis(30));
+    }
+
+    if (appendFailure.get() != null) {
+      Assert.fail("Appending thread failed: " + appendFailure.get());
+    }
+    Assert.assertFalse("appender thread is still running after join", 
appender.isAlive());
+    // The walk covers the three entries present when it started, in 
registration order.
+    Assert.assertEquals(Arrays.asList("first", "blocking", "third"), 
firstWalk);
+    Assert.assertEquals(3, spillCount.get());
+    Assert.assertEquals(0, appendedSpills.get());
+
+    // The spiller appended mid-walk did reach the list: it takes part in the 
next walk.
+    Assert.assertEquals(0, spillers.spill(target, Spiller.Phase.SPILL, 100));
+    Assert.assertEquals(1, appendedSpills.get());
+    Assert.assertEquals(6, spillCount.get());
+  }
+
+  @Test
+  public void testAppendDuringOwnSpill() {
+    // No production spiller appends today, but a lock around append would not 
cover one that did,
+    // so pin the re-entrant case as well.
+    final Spillers.AppendableSpillerList spillers = Spillers.appendable();
+    final MemoryTarget target = new NoopMemoryTarget();
+    final AtomicInteger appendedSpills = new AtomicInteger(0);
+    final AtomicInteger spillCount = new AtomicInteger(0);
+
+    spillers.append(
+        new Spiller() {
+          @Override
+          public long spill(MemoryTarget self, Phase phase, long size) {
+            spillCount.incrementAndGet();
+            spillers.append(countingSpiller(appendedSpills));
+            return 0L;
+          }
+        });
+    spillers.append(countingSpiller(spillCount));
+
+    Assert.assertEquals(0, spillers.spill(target, Spiller.Phase.SPILL, 100));
+    Assert.assertEquals(2, spillCount.get());
+    Assert.assertEquals(0, appendedSpills.get());
+    // The appended spiller takes part in the next walk.
+    Assert.assertEquals(0, spillers.spill(target, Spiller.Phase.SPILL, 100));
+    Assert.assertEquals(1, appendedSpills.get());
+  }
+
+  @Test
+  public void testSpillStopsOnceTheRequestIsMet() {
+    // Pins the pre-existing short-circuit in AppendableSpillerList#spill, not 
this fix: once the
+    // request is met the walk stops, so later entries are never consulted.
+    final Spillers.AppendableSpillerList spillers = Spillers.appendable();
+    final MemoryTarget target = new NoopMemoryTarget();
+    final AtomicInteger laterSpills = new AtomicInteger(0);
+
+    spillers.append(
+        new Spiller() {
+          @Override
+          public long spill(MemoryTarget self, Phase phase, long size) {
+            return size;
+          }
+        });
+    spillers.append(countingSpiller(laterSpills));
+
+    Assert.assertEquals(100, spillers.spill(target, Spiller.Phase.SPILL, 100));
+    Assert.assertEquals(0, laterSpills.get());
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to