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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 2a30dd9373 [core] Null out supplier in LazyField after evaluation to 
prevent memory leak (#7910)
2a30dd9373 is described below

commit 2a30dd9373ec6e2afdcd12b8996b8899e4b8e30c
Author: zhoulii <[email protected]>
AuthorDate: Wed May 20 17:17:04 2026 +0800

    [core] Null out supplier in LazyField after evaluation to prevent memory 
leak (#7910)
---
 .../java/org/apache/paimon/utils/LazyField.java    |   3 +-
 .../org/apache/paimon/utils/LazyFieldTest.java     | 160 +++++++++++++++++++++
 2 files changed, 162 insertions(+), 1 deletion(-)

diff --git a/paimon-common/src/main/java/org/apache/paimon/utils/LazyField.java 
b/paimon-common/src/main/java/org/apache/paimon/utils/LazyField.java
index 2bb7013625..8e9eb9e2f1 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/LazyField.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/LazyField.java
@@ -23,7 +23,7 @@ import java.util.function.Supplier;
 /** A class to lazy initialized field. */
 public class LazyField<T> {
 
-    private final Supplier<T> supplier;
+    private Supplier<T> supplier;
 
     private boolean initialized;
     private T value;
@@ -37,6 +37,7 @@ public class LazyField<T> {
             T t = supplier.get();
             value = t;
             initialized = true;
+            supplier = null; // release the closure chain for GC
             return t;
         }
         return value;
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/utils/LazyFieldTest.java 
b/paimon-common/src/test/java/org/apache/paimon/utils/LazyFieldTest.java
new file mode 100644
index 0000000000..452539ba50
--- /dev/null
+++ b/paimon-common/src/test/java/org/apache/paimon/utils/LazyFieldTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.paimon.utils;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link LazyField}. */
+public class LazyFieldTest {
+
+    @Test
+    void testLazyEvaluation() {
+        AtomicInteger counter = new AtomicInteger(0);
+        LazyField<String> lazyField =
+                new LazyField<>(
+                        () -> {
+                            counter.incrementAndGet();
+                            return "hello";
+                        });
+
+        assertThat(lazyField.initialized()).isFalse();
+        assertThat(counter.get()).isEqualTo(0);
+
+        assertThat(lazyField.get()).isEqualTo("hello");
+        assertThat(lazyField.initialized()).isTrue();
+        assertThat(counter.get()).isEqualTo(1);
+
+        // second call should not re-evaluate
+        assertThat(lazyField.get()).isEqualTo("hello");
+        assertThat(counter.get()).isEqualTo(1);
+    }
+
+    @Test
+    void testSupplierReleasedAfterGet() throws Exception {
+        LazyField<String> lazyField = new LazyField<>(() -> "hello");
+
+        java.lang.reflect.Field supplierField = 
LazyField.class.getDeclaredField("supplier");
+        supplierField.setAccessible(true);
+
+        // before evaluation, supplier is held
+        assertThat(supplierField.get(lazyField)).isNotNull();
+
+        lazyField.get();
+
+        // after evaluation, supplier is released for GC
+        assertThat(supplierField.get(lazyField)).isNull();
+    }
+
+    @Test
+    void testChainedLazyFieldsReleaseClosure() throws Exception {
+        // Simulates the GlobalIndexResult.or() chain scenario:
+        // K1 = lazy(() -> merge(r1, r2))
+        // K2 = lazy(() -> merge(K1.get(), r3))
+        // After K2.get(), K1's supplier should be released.
+
+        final byte[] resource1 = new byte[512 * 1024];
+        final byte[] resource2 = new byte[512 * 1024];
+
+        // K1 captures resource1
+        LazyField<String> k1 = new LazyField<>(() -> "r1=" + resource1.length);
+        // K2 captures K1 and resource2
+        LazyField<String> k2 = new LazyField<>(() -> k1.get() + ",r2=" + 
resource2.length);
+
+        // trigger full chain evaluation
+        String result = k2.get();
+        assertThat(result).isEqualTo("r1=524288,r2=524288");
+
+        // verify both suppliers are nulled after chain evaluation
+        java.lang.reflect.Field supplierField = 
LazyField.class.getDeclaredField("supplier");
+        supplierField.setAccessible(true);
+        assertThat(supplierField.get(k1)).isNull();
+        assertThat(supplierField.get(k2)).isNull();
+    }
+
+    @Test
+    void testIntermediateBitmapsReclaimableAfterChainEvaluation() {
+        // Simulates the real GlobalIndexResult.or() chain with 
RoaringNavigableMap64:
+        // Each or() creates a NEW intermediate bitmap via 
RoaringNavigableMap64.or(x1, x2).
+        // Before the fix, all intermediate bitmaps are retained forever.
+        // After the fix, intermediate bitmaps become reclaimable by GC.
+
+        final int chainLength = 10;
+        List<WeakReference<RoaringNavigableMap64>> intermediateRefs = new 
ArrayList<>();
+
+        // Build a chain: K0 = leaf, K1 = or(K0, leaf), K2 = or(K1, leaf), ...
+        // This mirrors UnionGlobalIndexReader#union() with N readers.
+        LazyField<RoaringNavigableMap64> current =
+                new LazyField<>(
+                        () -> {
+                            RoaringNavigableMap64 bitmap = new 
RoaringNavigableMap64();
+                            bitmap.add(0L);
+                            return bitmap;
+                        });
+
+        for (int i = 1; i <= chainLength; i++) {
+            final LazyField<RoaringNavigableMap64> prev = current;
+            final long value = i;
+            current =
+                    new LazyField<>(
+                            () -> {
+                                // This simulates RoaringNavigableMap64.or(x1, 
x2) creating a new
+                                // object
+                                RoaringNavigableMap64 left = prev.get();
+                                RoaringNavigableMap64 right = new 
RoaringNavigableMap64();
+                                right.add(value);
+                                RoaringNavigableMap64 merged =
+                                        RoaringNavigableMap64.or(left, right);
+                                // Track the intermediate result with a weak 
reference
+                                intermediateRefs.add(new 
WeakReference<>(merged));
+                                return merged;
+                            });
+        }
+
+        // Trigger the full chain evaluation
+        RoaringNavigableMap64 finalResult = current.get();
+
+        // Final result should contain all values 0..chainLength
+        for (long i = 0; i <= chainLength; i++) {
+            assertThat(finalResult.contains(i)).isTrue();
+        }
+
+        // After evaluation with the fix (supplier=null), intermediate bitmaps
+        // are no longer strongly referenced and can be reclaimed by GC.
+        // Without the fix, the supplier chain holds them all alive.
+        System.gc();
+
+        int reclaimedCount = 0;
+        for (WeakReference<RoaringNavigableMap64> ref : intermediateRefs) {
+            if (ref.get() == null) {
+                reclaimedCount++;
+            }
+        }
+
+        // The last merged bitmap is the finalResult (held by local variable),
+        // so 9 out of 10 intermediate bitmaps should be reclaimed.
+        assertThat(reclaimedCount).isEqualTo(chainLength - 1);
+    }
+}

Reply via email to