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

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


The following commit(s) were added to refs/heads/master by this push:
     new c3f56b5e9 fix(core): ref-count store event listener to fix owner-first 
close leak (#3058)
c3f56b5e9 is described below

commit c3f56b5e9f26dddab7201da9a14ecd9d8c869eb2
Author: Davide Polato <[email protected]>
AuthorDate: Wed Jun 17 06:39:35 2026 +0200

    fix(core): ref-count store event listener to fix owner-first close leak 
(#3058)
    
    storeEventListenStatus had the same owner-first-close bug #3017 fixed for
    graph cache listeners: non-owner close() dropped the entry and skipped
    unlisten() as a no-op, leaking the owner's listener.
    
    Apply CacheListenerHolder ref-count pattern: StoreListenerHolder +
    STORE_EVENT_LISTENERS in CachedGraphTransaction, acquire/release via
    compute() with provider-identity guard for close/reopen.
    
    Removes storeEventListenStatus from GraphTransaction and
    restoreStoreListenerStatusForKnownTeardownBug workaround from tests.
    CachedSchemaTransaction* unaffected (per-instance, balanced 1:1).
---
 .../backend/cache/CachedGraphTransaction.java      |  82 ++++++++--
 .../hugegraph/backend/tx/GraphTransaction.java     |   3 -
 .../unit/cache/CachedGraphTransactionTest.java     | 175 ++++++++++++++++++---
 3 files changed, 217 insertions(+), 43 deletions(-)

diff --git 
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedGraphTransaction.java
 
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedGraphTransaction.java
index dadfd7ec7..cf4384d81 100644
--- 
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedGraphTransaction.java
+++ 
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/cache/CachedGraphTransaction.java
@@ -35,6 +35,7 @@ import org.apache.hugegraph.backend.query.Query;
 import org.apache.hugegraph.backend.query.QueryResults;
 import org.apache.hugegraph.backend.store.BackendMutation;
 import org.apache.hugegraph.backend.store.BackendStore;
+import org.apache.hugegraph.backend.store.BackendStoreProvider;
 import org.apache.hugegraph.backend.store.ram.RamTable;
 import org.apache.hugegraph.backend.tx.GraphTransaction;
 import org.apache.hugegraph.config.CoreOptions;
@@ -70,12 +71,22 @@ public final class CachedGraphTransaction extends 
GraphTransaction {
     private static final ConcurrentMap<String, CacheListenerHolder>
             GRAPH_CACHE_EVENT_LISTENERS = new ConcurrentHashMap<>();
 
+    /*
+     * Same ref-counted lifecycle for the store event listener registered
+     * on the BackendStoreProvider; see StoreListenerHolder.
+     *
+     * Replaces the removed protected static storeEventListenStatus field
+     * that previously tracked store-listen state on GraphTransaction.
+     */
+    private static final ConcurrentMap<String, StoreListenerHolder>
+            STORE_EVENT_LISTENERS = new ConcurrentHashMap<>();
+
     private final Cache<Id, Object> verticesCache;
     private final Cache<Id, Object> edgesCache;
 
-    private EventListener storeEventListener;
     private EventListener cacheEventListener;
     private CacheListenerHolder holder;
+    private StoreListenerHolder storeHolder;
 
     public CachedGraphTransaction(HugeGraphParams graph, BackendStore store) {
         super(graph, store);
@@ -135,7 +146,7 @@ public final class CachedGraphTransaction extends 
GraphTransaction {
         Set<String> storeEvents = ImmutableSet.of(Events.STORE_INIT,
                                                   Events.STORE_CLEAR,
                                                   Events.STORE_TRUNCATE);
-        this.storeEventListener = event -> {
+        EventListener storeListener = event -> {
             if (storeEvents.contains(event.name())) {
                 LOG.debug("Graph {} clear graph cache on event '{}'",
                           this.graph(), event.name());
@@ -144,9 +155,24 @@ public final class CachedGraphTransaction extends 
GraphTransaction {
             }
             return false;
         };
-        if (storeEventListenStatus.putIfAbsent(this.params().spaceGraphName(), 
true) == null) {
-            this.store().provider().listen(this.storeEventListener);
-        }
+        BackendStoreProvider provider = this.store().provider();
+        String graphName = this.params().spaceGraphName();
+        StoreListenerHolder storeAcquired = STORE_EVENT_LISTENERS.compute(
+                graphName, (key, existing) -> {
+                    if (existing == null || existing.provider != provider) {
+                        // Graph close/reopen creates a new provider for the
+                        // same graph name; replace the stale holder. Old
+                        // transactions skip decrement via identity check.
+                        if (existing != null) {
+                            existing.provider.unlisten(existing.listener);
+                        }
+                        provider.listen(storeListener);
+                        return new StoreListenerHolder(storeListener, 
provider);
+                    }
+                    existing.refCount++;
+                    return existing;
+                });
+        this.storeHolder = storeAcquired;
 
         // Listen cache event: "cache"(invalid cache item)
         EventListener listener = event -> {
@@ -196,7 +222,6 @@ public final class CachedGraphTransaction extends 
GraphTransaction {
             return false;
         };
         EventHub graphEventHub = this.params().graphEventHub();
-        String graphName = this.params().spaceGraphName();
         CacheListenerHolder acquired = GRAPH_CACHE_EVENT_LISTENERS.compute(
                 graphName, (key, existing) -> {
                     if (existing == null || existing.hub != graphEventHub) {
@@ -235,14 +260,20 @@ public final class CachedGraphTransaction extends 
GraphTransaction {
             this.holder = null;
             this.cacheEventListener = null;
         }
-        // TODO (follow-up): storeEventListenStatus has the same owner-first
-        // close bug this PR fixes for GRAPH_CACHE_EVENT_LISTENERS. A non-owner
-        // transaction can remove the tracking entry, unlisten its own
-        // never-registered storeEventListener as a no-op, and leave the
-        // original store listener registered but untracked. Apply the same
-        // ref-counted holder pattern in a follow-up PR.
-        if (storeEventListenStatus.remove(graphName) != null) {
-            this.store().provider().unlisten(this.storeEventListener);
+        StoreListenerHolder storeOurs = this.storeHolder;
+        if (storeOurs != null) {
+            STORE_EVENT_LISTENERS.compute(graphName, (key, existing) -> {
+                if (existing == null || existing != storeOurs) {
+                    return existing;
+                }
+                existing.refCount--;
+                if (existing.refCount == 0) {
+                    existing.provider.unlisten(existing.listener);
+                    return null;
+                }
+                return existing;
+            });
+            this.storeHolder = null;
         }
     }
 
@@ -476,4 +507,27 @@ public final class CachedGraphTransaction extends 
GraphTransaction {
             }
         }
     }
+
+    /*
+     * Listener lifetime must cover all active transactions for the graph.
+     * The holder is removed from the registry and unregistered from the
+     * BackendStoreProvider only when the last transaction releases it.
+     * Mirror of CacheListenerHolder for the store event path.
+     */
+    private static final class StoreListenerHolder {
+
+        final EventListener listener;
+        final BackendStoreProvider provider;
+        // Must only be read or written inside ConcurrentMap.compute() for the
+        // enclosing registry; ConcurrentHashMap.compute() serialises per-key
+        // access.
+        int refCount;
+
+        StoreListenerHolder(EventListener listener,
+                            BackendStoreProvider provider) {
+            this.listener = listener;
+            this.provider = provider;
+            this.refCount = 1;
+        }
+    }
 }
diff --git 
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/tx/GraphTransaction.java
 
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/tx/GraphTransaction.java
index 5e33e0b3f..a0792ee99 100644
--- 
a/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/tx/GraphTransaction.java
+++ 
b/hugegraph-server/hugegraph-core/src/main/java/org/apache/hugegraph/backend/tx/GraphTransaction.java
@@ -28,7 +28,6 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.BiFunction;
 import java.util.function.Consumer;
 import java.util.function.Function;
@@ -140,8 +139,6 @@ public class GraphTransaction extends IndexableTransaction {
 
     private final int verticesCapacity;
     private final int edgesCapacity;
-    protected static final ConcurrentHashMap<String, Boolean> 
storeEventListenStatus =
-            new ConcurrentHashMap<>();
 
     public GraphTransaction(HugeGraphParams graph, BackendStore store) {
         super(graph, store);
diff --git 
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cache/CachedGraphTransactionTest.java
 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cache/CachedGraphTransactionTest.java
index 7bcc1a7fe..b128f277c 100644
--- 
a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cache/CachedGraphTransactionTest.java
+++ 
b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/cache/CachedGraphTransactionTest.java
@@ -30,7 +30,8 @@ import org.apache.hugegraph.backend.cache.Cache;
 import org.apache.hugegraph.backend.cache.CachedGraphTransaction;
 import org.apache.hugegraph.backend.id.Id;
 import org.apache.hugegraph.backend.id.IdGenerator;
-import org.apache.hugegraph.backend.tx.GraphTransaction;
+import org.apache.hugegraph.backend.store.BackendStoreProvider;
+import org.apache.hugegraph.event.EventHub;
 import org.apache.hugegraph.event.EventListener;
 import org.apache.hugegraph.schema.VertexLabel;
 import org.apache.hugegraph.structure.HugeEdge;
@@ -93,20 +94,13 @@ public class CachedGraphTransactionTest extends 
BaseUnitTest {
     }
 
     @SuppressWarnings("unchecked")
-    private static ConcurrentMap<String, Boolean> storeEventListenStatus()
+    private static ConcurrentMap<String, Object> storeEventListeners()
             throws Exception {
-        Field field = GraphTransaction.class
-                                      
.getDeclaredField("storeEventListenStatus");
+        Field field = CachedGraphTransaction.class
+                                            .getDeclaredField(
+                                                    "STORE_EVENT_LISTENERS");
         field.setAccessible(true);
-        return (ConcurrentMap<String, Boolean>) field.get(null);
-    }
-
-    private static void restoreStoreListenerStatusForKnownTeardownBug(
-            ConcurrentMap<String, Boolean> storeListeners, String graphName) {
-        // Closing a secondary transaction can consume storeEventListenStatus 
due
-        // to the follow-up bug documented in 
CachedGraphTransaction.unlistenChanges().
-        // Restore it so teardown can still unregister the primary store 
listener.
-        storeListeners.putIfAbsent(graphName, true);
+        return (ConcurrentMap<String, Object>) field.get(null);
     }
 
     private static EventListener holderListener(Object holder) {
@@ -118,6 +112,10 @@ public class CachedGraphTransactionTest extends 
BaseUnitTest {
         return refCount;
     }
 
+    private static BackendStoreProvider holderProvider(Object holder) {
+        return Whitebox.getInternalState(holder, "provider");
+    }
+
     private HugeVertex newVertex(Id id) {
         HugeGraph graph = this.cache().graph();
         graph.schema().propertyKey("name").asText()
@@ -251,8 +249,6 @@ public class CachedGraphTransactionTest extends 
BaseUnitTest {
             throws Exception {
         ConcurrentMap<String, Object> cacheListeners =
                 graphCacheEventListeners();
-        ConcurrentMap<String, Boolean> storeListeners =
-                storeEventListenStatus();
 
         String graphName = this.params.spaceGraphName();
         Object holder = cacheListeners.get(graphName);
@@ -265,18 +261,58 @@ public class CachedGraphTransactionTest extends 
BaseUnitTest {
         Assert.assertSame(holder, cacheListeners.get(graphName));
         Assert.assertEquals(refCount + 1, holderRefCount(holder));
 
-        try {
-            second.close();
+        second.close();
 
-            Assert.assertSame(holder, cacheListeners.get(graphName));
-            Assert.assertEquals(refCount, holderRefCount(holder));
-            Assert.assertTrue(this.params.graphEventHub()
-                                         .listeners(Events.CACHE)
-                                         .contains(registered));
-        } finally {
-            restoreStoreListenerStatusForKnownTeardownBug(storeListeners,
-                                                           graphName);
-        }
+        Assert.assertSame(holder, cacheListeners.get(graphName));
+        Assert.assertEquals(refCount, holderRefCount(holder));
+        Assert.assertTrue(this.params.graphEventHub()
+                                     .listeners(Events.CACHE)
+                                     .contains(registered));
+    }
+
+    @Test
+    public void testClosingNonOwnerKeepsStoreListenerRegistered()
+            throws Exception {
+        ConcurrentMap<String, Object> storeListeners = storeEventListeners();
+
+        String graphName = this.params.spaceGraphName();
+        Object holder = storeListeners.get(graphName);
+        Assert.assertNotNull(holder);
+        EventListener registered = holderListener(holder);
+        int refCount = holderRefCount(holder);
+
+        CachedGraphTransaction second = new CachedGraphTransaction(
+                this.params, this.params.loadGraphStore());
+        Assert.assertSame(holder, storeListeners.get(graphName));
+        Assert.assertEquals(refCount + 1, holderRefCount(holder));
+
+        second.close();
+
+        // Non-owner close must decrement the refcount, not drop the entry
+        Assert.assertSame(holder, storeListeners.get(graphName));
+        Assert.assertEquals(refCount, holderRefCount(holder));
+        Assert.assertSame(registered, holderListener(holder));
+    }
+
+    @Test
+    public void testLastCloseRemovesStoreListener() throws Exception {
+        ConcurrentMap<String, Object> storeListeners = storeEventListeners();
+
+        String graphName = this.params.spaceGraphName();
+        CachedGraphTransaction owner = this.cache();
+        CachedGraphTransaction second = new CachedGraphTransaction(
+                this.params, this.params.loadGraphStore());
+
+        Object holder = storeListeners.get(graphName);
+        Assert.assertNotNull(holder);
+        Assert.assertTrue(holderRefCount(holder) >= 2);
+
+        owner.close();
+        second.close();
+        this.cache = null;
+        this.params.graphTransaction().close();
+
+        Assert.assertFalse(storeListeners.containsKey(graphName));
     }
 
     @Test
@@ -319,6 +355,93 @@ public class CachedGraphTransactionTest extends 
BaseUnitTest {
                             Whitebox.invoke(second, "verticesCache", "size"));
     }
 
+    @Test
+    public void testStoreListenerSurvivesOwnerClose() throws Exception {
+        ConcurrentMap<String, Object> storeListeners = storeEventListeners();
+        String graphName = this.params.spaceGraphName();
+        CachedGraphTransaction owner = this.cache();
+        CachedGraphTransaction second = new CachedGraphTransaction(
+                this.params, this.params.loadGraphStore());
+
+        Object holder = storeListeners.get(graphName);
+        Assert.assertNotNull(holder);
+        EventListener registered = holderListener(holder);
+        BackendStoreProvider provider = holderProvider(holder);
+        int refCount = holderRefCount(holder);
+        Assert.assertTrue(refCount >= 2);
+
+        owner.close();
+        this.cache = second;
+
+        Assert.assertSame(holder, storeListeners.get(graphName));
+        Assert.assertEquals(refCount - 1, holderRefCount(holder));
+        Assert.assertTrue(provider.storeEventHub()
+                                  .listeners(EventHub.ANY_EVENT)
+                                  .contains(registered));
+
+        second.addVertex(this.newVertex(IdGenerator.of(1)));
+        second.addVertex(this.newVertex(IdGenerator.of(2)));
+        second.commit();
+        Assert.assertTrue(second.queryVertices(IdGenerator.of(1)).hasNext());
+        Assert.assertTrue(second.queryVertices(IdGenerator.of(2)).hasNext());
+        Assert.assertEquals(2L,
+                            Whitebox.invoke(second, "verticesCache", "size"));
+
+        // Owner is closed first; the surviving transaction must still observe
+        // the store event through the ref-counted provider listener and clear
+        // its cache.
+        provider.storeEventHub().notify(Events.STORE_CLEAR, provider).get();
+
+        Assert.assertEquals(0L,
+                            Whitebox.invoke(second, "verticesCache", "size"));
+    }
+
+    @Test
+    public void testReopenGraphReRegistersStoreListener() throws Exception {
+        ConcurrentMap<String, Object> storeListeners = storeEventListeners();
+        String graphName = this.params.spaceGraphName();
+        CachedGraphTransaction owner = this.cache();
+
+        Object holder = storeListeners.get(graphName);
+        Assert.assertNotNull(holder);
+        EventListener registered = holderListener(holder);
+        BackendStoreProvider provider = holderProvider(holder);
+
+        owner.close();
+        this.cache = null;
+        this.params.graphTransaction().close();
+
+        // Last close drops the registry entry and unregisters the listener.
+        Assert.assertFalse(storeListeners.containsKey(graphName));
+        Assert.assertFalse(provider.storeEventHub()
+                                   .listeners(EventHub.ANY_EVENT)
+                                   .contains(registered));
+
+        this.graph.clearBackend();
+        this.graph.close();
+        this.graph = null;
+
+        HugeGraph reopened = HugeFactory.open(FakeObjects.newConfig());
+        this.graph = reopened;
+        this.params = Whitebox.getInternalState(reopened, "params");
+        CachedGraphTransaction third = new CachedGraphTransaction(
+                this.params, this.params.loadGraphStore());
+        this.cache = third;
+
+        // Reopen registers a fresh holder for the same graph name, and its
+        // listener is wired to the store provider again (no stale leftover).
+        // The provider instance is pooled and reused across reopen, so the
+        // provider-replacement branch is not exercised here.
+        Object reopenedHolder = storeListeners.get(graphName);
+        Assert.assertNotNull(reopenedHolder);
+        Assert.assertNotSame(holder, reopenedHolder);
+        EventListener reopenedListener = holderListener(reopenedHolder);
+        Assert.assertNotSame(registered, reopenedListener);
+        Assert.assertTrue(holderProvider(reopenedHolder).storeEventHub()
+                                       .listeners(EventHub.ANY_EVENT)
+                                       .contains(reopenedListener));
+    }
+
     @Test
     public void testLastCloseRemovesGraphCacheListener() throws Exception {
         ConcurrentMap<String, Object> cacheListeners =

Reply via email to