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 08d4019269 [GLUTEN-12424][CORE] Fix DynamicOffHeapSizingMemoryTarget 
ledger drift when the wrapped target grants or frees partially (#12425)
08d4019269 is described below

commit 08d40192695f8ebc5a7351869985c80780cf3a26
Author: YangJie <[email protected]>
AuthorDate: Fri Jul 3 15:29:25 2026 +0800

    [GLUTEN-12424][CORE] Fix DynamicOffHeapSizingMemoryTarget ledger drift when 
the wrapped target grants or frees partially (#12425)
---
 .../DynamicOffHeapSizingMemoryTarget.java          |  43 ++-
 .../DynamicOffHeapSizingMemoryTargetTest.java      | 401 +++++++++++++++++++++
 2 files changed, 436 insertions(+), 8 deletions(-)

diff --git 
a/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/DynamicOffHeapSizingMemoryTarget.java
 
b/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/DynamicOffHeapSizingMemoryTarget.java
index 396a80b100..5898ae45e2 100644
--- 
a/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/DynamicOffHeapSizingMemoryTarget.java
+++ 
b/gluten-core/src/main/java/org/apache/gluten/memory/memtarget/DynamicOffHeapSizingMemoryTarget.java
@@ -19,6 +19,7 @@ package org.apache.gluten.memory.memtarget;
 import org.apache.gluten.memory.SimpleMemoryUsageRecorder;
 import org.apache.gluten.proto.MemoryUsageStats;
 
+import com.google.common.annotations.VisibleForTesting;
 import org.apache.spark.annotation.Experimental;
 import org.apache.spark.util.SparkThreadPoolUtil;
 import org.slf4j.Logger;
@@ -127,6 +128,20 @@ public class DynamicOffHeapSizingMemoryTarget implements 
MemoryTarget, KnownName
   // Test only.
   private static final AtomicLong TOTAL_EXPLICIT_GC_COUNT = new AtomicLong(0L);
 
+  // Package-private test hooks — the ledger-correctness tests need to observe 
the static
+  // counter that gates exceedsMaxMemoryUsage(), and to reset it between test 
methods to avoid
+  // order-dependent state leakage. Not for production use; @VisibleForTesting 
flags any accidental
+  // same-package caller.
+  @VisibleForTesting
+  static long usedOffHeapBytesForTesting() {
+    return USED_OFF_HEAP_BYTES.get();
+  }
+
+  @VisibleForTesting
+  static void resetUsedOffHeapBytesForTesting() {
+    USED_OFF_HEAP_BYTES.set(0L);
+  }
+
   private final String name = 
MemoryTargetUtil.toUniqueName("DynamicOffHeapSizing");
   private final SimpleMemoryUsageRecorder recorder = new 
SimpleMemoryUsageRecorder();
 
@@ -199,18 +214,30 @@ public class DynamicOffHeapSizingMemoryTarget implements 
MemoryTarget, KnownName
           });
     }
 
-    USED_OFF_HEAP_BYTES.addAndGet(size);
-    recorder.inc(size);
-    target.borrow(size);
-    return size;
+    // Record only what the wrapped target actually grants. Booking the 
requested
+    // `size` up-front (as the previous implementation did) inflates the 
counters
+    // for the window between the addAndGet and the target.borrow return, and
+    // would leave phantom bytes recorded permanently when the target grants
+    // less than requested — causing subsequent `exceedsMaxMemoryUsage` checks
+    // to reject allocations that were in fact within budget.
+    final long granted = target.borrow(size);
+    USED_OFF_HEAP_BYTES.addAndGet(granted);
+    recorder.inc(granted);
+    return granted;
   }
 
   @Override
   public long repay(long size) {
-    USED_OFF_HEAP_BYTES.addAndGet(-size);
-    recorder.inc(-size);
-    target.repay(size);
-    return size;
+    if (size == 0) {
+      return 0;
+    }
+    // Same rationale as borrow: reconcile against what the wrapped target
+    // actually freed, so under-frees don't leave the counters below zero and
+    // over-frees don't leak accounting.
+    final long freed = target.repay(size);
+    USED_OFF_HEAP_BYTES.addAndGet(-freed);
+    recorder.inc(-freed);
+    return freed;
   }
 
   @Override
diff --git 
a/gluten-core/src/test/java/org/apache/gluten/memory/memtarget/DynamicOffHeapSizingMemoryTargetTest.java
 
b/gluten-core/src/test/java/org/apache/gluten/memory/memtarget/DynamicOffHeapSizingMemoryTargetTest.java
new file mode 100644
index 0000000000..c2f39b5fd0
--- /dev/null
+++ 
b/gluten-core/src/test/java/org/apache/gluten/memory/memtarget/DynamicOffHeapSizingMemoryTargetTest.java
@@ -0,0 +1,401 @@
+/*
+ * 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.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Ledger-correctness tests for {@link DynamicOffHeapSizingMemoryTarget}.
+ *
+ * <p>Regression this class guards against: the wrapped {@code 
target.borrow(size)} can grant less
+ * than {@code size} (Spark's execution memory pool is allowed to return a 
partial reservation).
+ * Booking the requested amount into both {@code USED_OFF_HEAP_BYTES} and the 
local recorder — as
+ * the pre-fix code did — caused the counters to drift above the true 
reservation. That drift makes
+ * the class's own {@code exceedsMaxMemoryUsage()} gate reject subsequent 
borrows that are in fact
+ * within budget. {@code repay} had the mirrored bug.
+ *
+ * <p>Assertions cover both counters. {@link 
DynamicOffHeapSizingMemoryTarget#usedBytes()} reads the
+ * per-instance recorder, but the actual gate is the static {@code 
USED_OFF_HEAP_BYTES}, so a
+ * regression that fixed one and re-broke the other would slip past an 
assertions-on-recorder-only
+ * suite; we verify both via package-private test hooks.
+ *
+ * <p><b>JVM-heap assumption.</b> The {@code borrow(long)} implementation 
enters an on-heap shrink
+ * path when {@code Runtime.totalMemory() + size >= Runtime.maxMemory()}. 
These tests use small
+ * allocations (≤128 bytes) and assume the surefire JVM has room to spare, 
which holds under
+ * Gluten's default test JVM. A future config that pins the heap at its max 
from process start (e.g.
+ * {@code -Xms} == {@code -Xmx}) would send every borrow through the shrink 
branch; if that happens,
+ * add a test-only injection seam for {@code Runtime.totalMemory()} rather 
than relaxing the ledger
+ * assertions.
+ */
+public class DynamicOffHeapSizingMemoryTargetTest {
+
+  @Before
+  public void resetStaticCounter() {
+    DynamicOffHeapSizingMemoryTarget.resetUsedOffHeapBytesForTesting();
+  }
+
+  @After
+  public void confirmStaticCounterZero() {
+    // Every test must leave the shared JVM-wide counter clean, so downstream 
tests in the same
+    // fork are not perturbed. A test that fails this assertion is either 
missing a repay or is
+    // exposing a real accounting leak.
+    Assert.assertEquals(
+        "static USED_OFF_HEAP_BYTES leaked past a test method",
+        0L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+  }
+
+  @Test
+  public void borrowRecordsOnlyTheGrantedAmountForFullGrants() {
+    final CountingTarget wrapped = new CountingTarget(Long.MAX_VALUE);
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    final long granted = target.borrow(128L);
+
+    Assert.assertEquals("full grant returned verbatim", 128L, granted);
+    Assert.assertEquals("recorder tracks the grant", 128L, target.usedBytes());
+    Assert.assertEquals(
+        "static counter tracks the grant",
+        128L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+    Assert.assertEquals("wrapped target saw the same size", 128L, 
wrapped.currentReserved());
+
+    // Restore the invariant for the @After check.
+    target.repay(128L);
+  }
+
+  @Test
+  public void borrowRecordsOnlyTheGrantedAmountForPartialGrants() {
+    // Wrapped target can only grant 40 out of every 100 requested.
+    final CountingTarget wrapped = new CountingTarget(40L);
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    final long granted = target.borrow(100L);
+
+    Assert.assertEquals("return value reflects the actual grant", 40L, 
granted);
+    Assert.assertEquals("recorder must not book the 60-byte overshoot", 40L, 
target.usedBytes());
+    Assert.assertEquals(
+        "static counter must not book the 60-byte overshoot — this is the gate 
for the class's own"
+            + " exceedsMaxMemoryUsage() check, so drift here is the whole 
regression",
+        40L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+    Assert.assertEquals("wrapped state matches", 40L, 
wrapped.currentReserved());
+
+    target.repay(40L);
+  }
+
+  @Test
+  public void borrowRecordsZeroWhenTargetGrantsNothing() {
+    final CountingTarget wrapped = new CountingTarget(0L);
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    final long granted = target.borrow(64L);
+
+    Assert.assertEquals("zero grant surfaces to caller", 0L, granted);
+    // The class either consults the wrapped target once (JVM-heap-headroom 
path) or short-circuits
+    // on OOM before ever calling it — both are correct outcomes for the 
ledger. Anything above 1
+    // would mean DynamicOffHeapSizingMemoryTarget.borrow developed an 
internal retry loop, which
+    // is not a behaviour we want to sneak in silently.
+    Assert.assertTrue(
+        "wrapped target must be consulted at most once — a call count above 1 
would indicate an"
+            + " unintended retry loop inside 
DynamicOffHeapSizingMemoryTarget.borrow",
+        wrapped.borrowCalls() <= 1);
+    Assert.assertEquals("recorder untouched on zero grant", 0L, 
target.usedBytes());
+    Assert.assertEquals(
+        "static counter untouched on zero grant",
+        0L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+  }
+
+  @Test
+  public void sequentialPartialBorrowsAccumulateInBothLedgers() {
+    // The other partial-grant test measures a single call. Confirm the ledger 
*accumulates* the
+    // grants — a regression that used AtomicLong.set(...) instead of 
addAndGet(...) would ship
+    // green against a single-call suite and reintroduce the exact drift this 
class guards.
+    final CountingTarget wrapped = new CountingTarget(40L);
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    Assert.assertEquals(40L, target.borrow(100L));
+    Assert.assertEquals(40L, target.borrow(100L));
+
+    Assert.assertEquals("recorder accumulates across calls", 80L, 
target.usedBytes());
+    Assert.assertEquals(
+        "static counter accumulates across calls",
+        80L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+
+    target.repay(80L);
+  }
+
+  @Test
+  public void repayReturnsAndRecordsOnlyTheAmountActuallyFreed() {
+    // Grant 100 via borrow so all three ledgers (fake, recorder, static 
counter) start in sync
+    // at 100. Then ask the target to repay 100 but let it only free 30.
+    final PartialRepayTarget wrapped = new PartialRepayTarget(30L);
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    Assert.assertEquals(100L, target.borrow(100L));
+    Assert.assertEquals(100L, target.usedBytes());
+    Assert.assertEquals(100L, 
DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+
+    final long freed = target.repay(100L);
+
+    Assert.assertEquals("repay returns what the wrapped target freed", 30L, 
freed);
+    Assert.assertEquals(
+        "recorder decrements by freed bytes, not requested bytes", 70L, 
target.usedBytes());
+    Assert.assertEquals(
+        "static counter decrements by freed bytes, not requested bytes",
+        70L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+
+    // Drain the remaining 70 bytes so the @After check passes. The fake frees 
30 per call
+    // (bounded by the reserved held), so three more repays clear it (30 + 30 
+ 10).
+    Assert.assertEquals(30L, target.repay(70L));
+    Assert.assertEquals(30L, target.repay(40L));
+    Assert.assertEquals(10L, target.repay(10L));
+    Assert.assertEquals(0L, target.usedBytes());
+  }
+
+  @Test
+  public void borrowOfZeroIsANoOp() {
+    // Guardrail: the short-circuit for size==0 predates this fix; verify it 
still holds so we
+    // don't accidentally introduce a spurious target.borrow(0) call through 
the fix.
+    final CountingTarget wrapped = new CountingTarget(Long.MAX_VALUE);
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    Assert.assertEquals(0L, target.borrow(0L));
+    Assert.assertEquals("no interaction with wrapped target on size==0", 0, 
wrapped.borrowCalls());
+  }
+
+  @Test
+  public void repayOfZeroIsANoOp() {
+    // Symmetric guardrail for repay(0): must not reach the wrapped target and 
must not perturb
+    // either counter.
+    final CountingTarget wrapped = new CountingTarget(Long.MAX_VALUE);
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    Assert.assertEquals(0L, target.repay(0L));
+    Assert.assertEquals("no interaction with wrapped target on size==0", 0, 
wrapped.repayCalls());
+    Assert.assertEquals(
+        "static counter untouched",
+        0L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+  }
+
+  @Test
+  public void borrowLeavesLedgerUntouchedWhenWrappedTargetThrows() {
+    // The whole point of the fix is that USED_OFF_HEAP_BYTES / recorder are 
only mutated AFTER
+    // target.borrow returns normally. A regression back to the pre-fix 
ordering (increment first,
+    // then borrow) would leak the requested size on every throwing borrow. 
This test locks that
+    // in. Under the JVM-heap assumption documented on the class the wrapped 
target's throw is
+    // observed; if the class's own OOM early-return fires first 
(heap-constrained JVM) the
+    // wrapped throw is never reached — both outcomes leave the ledger 
untouched, which is what
+    // the fix actually guards.
+    final ThrowingBorrowTarget wrapped = new ThrowingBorrowTarget();
+    final DynamicOffHeapSizingMemoryTarget target = new 
DynamicOffHeapSizingMemoryTarget(wrapped);
+
+    long returned = 0L;
+    RuntimeException observed = null;
+    try {
+      returned = target.borrow(256L);
+    } catch (RuntimeException e) {
+      observed = e;
+    }
+    if (observed != null) {
+      Assert.assertEquals(
+          "wrapped target's throw surfaced verbatim", "simulated", 
observed.getMessage());
+    } else {
+      Assert.assertEquals(
+          "if the class OOM-early-returned instead of consulting the target, 
it must return 0",
+          0L,
+          returned);
+    }
+    Assert.assertEquals("recorder untouched", 0L, target.usedBytes());
+    Assert.assertEquals(
+        "static counter untouched regardless of which branch fired",
+        0L,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+  }
+
+  @Test
+  public void repayLeavesLedgerUntouchedWhenWrappedTargetThrows() {
+    // Symmetric guard for repay: pre-fix decremented before calling 
target.repay, so a throw
+    // there would silently under-count. Snapshot the static counter, run a 
throwing repay on a
+    // separate DOHS instance (so its instance recorder starts at 0 and no 
seeder is needed), and
+    // assert the shared static counter didn't move. No JVM-heap assumption 
needed — repay has no
+    // shrink path.
+    final long beforeStatic = 
DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting();
+
+    final ThrowingRepayTarget throwing = new ThrowingRepayTarget();
+    final DynamicOffHeapSizingMemoryTarget repayer = new 
DynamicOffHeapSizingMemoryTarget(throwing);
+    try {
+      repayer.repay(50L);
+      Assert.fail("expected wrapped target to throw");
+    } catch (RuntimeException expected) {
+      Assert.assertEquals("simulated", expected.getMessage());
+    }
+    Assert.assertEquals("throwing repayer's recorder untouched", 0L, 
repayer.usedBytes());
+    Assert.assertEquals(
+        "static counter untouched by the throwing repay",
+        beforeStatic,
+        DynamicOffHeapSizingMemoryTarget.usedOffHeapBytesForTesting());
+  }
+
+  /**
+   * Fake target: grants at most {@link #grantPerCall} bytes per call, matches 
Spark's contract of
+   * granting between 0 and the requested size inclusive. {@link #repay(long)} 
is a plain
+   * capacity-bounded free (mirrors {@link
+   * org.apache.gluten.memory.memtarget.spark.TreeMemoryConsumer.Node#repay}). 
The partial-repay
+   * case lives in {@link PartialRepayTarget} instead of folding both 
behaviours into one fake.
+   */
+  private static final class CountingTarget implements MemoryTarget {
+    private final long grantPerCall;
+    private long reserved = 0L;
+    private int borrowCalls = 0;
+    private int repayCalls = 0;
+
+    CountingTarget(long grantPerCall) {
+      this.grantPerCall = grantPerCall;
+    }
+
+    @Override
+    public long borrow(long size) {
+      borrowCalls++;
+      final long granted = Math.min(size, grantPerCall);
+      reserved += granted;
+      return granted;
+    }
+
+    @Override
+    public long repay(long size) {
+      repayCalls++;
+      final long freed = Math.min(size, reserved);
+      reserved -= freed;
+      return freed;
+    }
+
+    @Override
+    public long usedBytes() {
+      return reserved;
+    }
+
+    @Override
+    public <T> T accept(MemoryTargetVisitor<T> visitor) {
+      throw new UnsupportedOperationException("not needed for these tests");
+    }
+
+    long currentReserved() {
+      return reserved;
+    }
+
+    int borrowCalls() {
+      return borrowCalls;
+    }
+
+    int repayCalls() {
+      return repayCalls;
+    }
+  }
+
+  /**
+   * Fake target modelling {@link
+   * org.apache.gluten.memory.memtarget.spark.TreeMemoryConsumer.Node#repay}'s 
partial-free
+   * behaviour: {@code repay} returns {@code min(size, freePerCall, 
reserved)}, so it obeys the
+   * production invariant that a target never frees more than requested.
+   */
+  private static final class PartialRepayTarget implements MemoryTarget {
+    private long reserved = 0L;
+    private final long freePerCall;
+
+    PartialRepayTarget(long freePerCall) {
+      this.freePerCall = freePerCall;
+    }
+
+    @Override
+    public long borrow(long size) {
+      final long granted = Math.min(size, Long.MAX_VALUE - reserved);
+      reserved += granted;
+      return granted;
+    }
+
+    @Override
+    public long repay(long size) {
+      // freed = min(freePerCall, size, reserved) — respects both the "no more 
than requested"
+      // production invariant and the fake's "at most freePerCall per call" 
ceiling.
+      final long freed = Math.min(Math.min(freePerCall, size), reserved);
+      reserved -= freed;
+      return freed;
+    }
+
+    @Override
+    public long usedBytes() {
+      return reserved;
+    }
+
+    @Override
+    public <T> T accept(MemoryTargetVisitor<T> visitor) {
+      throw new UnsupportedOperationException("not needed for these tests");
+    }
+  }
+
+  private static final class ThrowingBorrowTarget implements MemoryTarget {
+    @Override
+    public long borrow(long size) {
+      throw new RuntimeException("simulated");
+    }
+
+    @Override
+    public long repay(long size) {
+      return 0L;
+    }
+
+    @Override
+    public long usedBytes() {
+      return 0L;
+    }
+
+    @Override
+    public <T> T accept(MemoryTargetVisitor<T> visitor) {
+      throw new UnsupportedOperationException("not needed for these tests");
+    }
+  }
+
+  private static final class ThrowingRepayTarget implements MemoryTarget {
+    @Override
+    public long borrow(long size) {
+      return 0L;
+    }
+
+    @Override
+    public long repay(long size) {
+      throw new RuntimeException("simulated");
+    }
+
+    @Override
+    public long usedBytes() {
+      return 0L;
+    }
+
+    @Override
+    public <T> T accept(MemoryTargetVisitor<T> visitor) {
+      throw new UnsupportedOperationException("not needed for these tests");
+    }
+  }
+}


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

Reply via email to