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

mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 9987ff1f12 Fixed: EntityEcaUtil cache-rebuild race under concurrent 
Delegator construction (OFBIZ-13517)
9987ff1f12 is described below

commit 9987ff1f1249748fbe406f45a7a806ccc2ea1bd3
Author: Mridul Pathak <[email protected]>
AuthorDate: Tue Sep 1 20:07:28 2026 +0530

    Fixed: EntityEcaUtil cache-rebuild race under concurrent Delegator 
construction (OFBIZ-13517)
    
    Threads racing a cache miss on the same entity-eca-reader each 
independently rebuilt the full cache before only one publish won. Added a lock 
with double-checked re-verification around the check-then-build-then-publish 
sequence, mirroring the sibling ServiceEcaUtil race fix in 485496ad3e (#1805).
---
 .../apache/ofbiz/entityext/eca/EntityEcaUtil.java  |  18 ++-
 .../eca/EntityEcaUtilConcurrencyTest.groovy        | 160 +++++++++++++++++++++
 2 files changed, 174 insertions(+), 4 deletions(-)

diff --git 
a/framework/entityext/src/main/java/org/apache/ofbiz/entityext/eca/EntityEcaUtil.java
 
b/framework/entityext/src/main/java/org/apache/ofbiz/entityext/eca/EntityEcaUtil.java
index 46e1350566..13c7dbba27 100644
--- 
a/framework/entityext/src/main/java/org/apache/ofbiz/entityext/eca/EntityEcaUtil.java
+++ 
b/framework/entityext/src/main/java/org/apache/ofbiz/entityext/eca/EntityEcaUtil.java
@@ -52,15 +52,25 @@ public final class EntityEcaUtil {
     private static final UtilCache<String, Map<String, Map<String, 
List<EntityEcaRule>>>> ENTITY_ECA_READERS =
             UtilCache.createUtilCache("entity.EcaReaders", 0, 0, false);
 
+    // Guards getEntityEcaCache()'s check-then-build-then-publish sequence: 
concurrent Delegator
+    // construction (see GenericDelegator.initEntityEcaHandler()) can race a 
cache miss for the same
+    // reader name, each redundantly rebuilding before only one publish wins.
+    private static final Object CONFIG_LOCK = new Object();
+
     private EntityEcaUtil() { }
 
     public static Map<String, Map<String, List<EntityEcaRule>>> 
getEntityEcaCache(String entityEcaReaderName) {
         Map<String, Map<String, List<EntityEcaRule>>> ecaCache = 
ENTITY_ECA_READERS.get(entityEcaReaderName);
         if (ecaCache == null) {
-            // FIXME: Collections are not thread safe
-            ecaCache = new HashMap<>();
-            readConfig(entityEcaReaderName, ecaCache);
-            ecaCache = 
ENTITY_ECA_READERS.putIfAbsentAndGet(entityEcaReaderName, ecaCache);
+            synchronized (CONFIG_LOCK) {
+                // Re-check: another thread may have already published this 
while we waited for the lock.
+                ecaCache = ENTITY_ECA_READERS.get(entityEcaReaderName);
+                if (ecaCache == null) {
+                    ecaCache = new HashMap<>();
+                    readConfig(entityEcaReaderName, ecaCache);
+                    ecaCache = 
ENTITY_ECA_READERS.putIfAbsentAndGet(entityEcaReaderName, ecaCache);
+                }
+            }
         }
         return ecaCache;
     }
diff --git 
a/framework/entityext/src/test/groovy/org/apache/ofbiz/entityext/eca/EntityEcaUtilConcurrencyTest.groovy
 
b/framework/entityext/src/test/groovy/org/apache/ofbiz/entityext/eca/EntityEcaUtilConcurrencyTest.groovy
new file mode 100644
index 0000000000..696b314d61
--- /dev/null
+++ 
b/framework/entityext/src/test/groovy/org/apache/ofbiz/entityext/eca/EntityEcaUtilConcurrencyTest.groovy
@@ -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.ofbiz.entityext.eca
+
+import org.apache.logging.log4j.LogManager
+import org.apache.logging.log4j.core.Logger as Log4jCoreLogger
+import org.apache.logging.log4j.core.LogEvent
+import org.apache.logging.log4j.core.appender.AbstractAppender
+import org.apache.logging.log4j.core.config.Property
+import org.apache.ofbiz.base.util.cache.UtilCache
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Timeout
+
+import java.util.concurrent.Callable
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Regression test for a check-then-act race on 
EntityEcaUtil#getEntityEcaCache(): concurrent
+ * cache misses for the same reader name used to each rebuild independently 
instead of one
+ * winning and the rest reusing it. Clears the real "main" reader's cache 
entry, then hammers
+ * getEntityEcaCache("main") from many threads at once, counting resource-load 
log lines to
+ * detect more than one rebuild happening.
+ */
+class EntityEcaUtilConcurrencyTest {
+
+    private static final String READER_NAME = 'main'
+    private static final int RACER_THREADS = 24
+    private static final int ITERATIONS = 10
+
+    private static final class CountingAppender extends AbstractAppender {
+
+        private final AtomicInteger count = new AtomicInteger(0)
+
+        CountingAppender() {
+            super('EntityEcaUtilConcurrencyTest-CountingAppender', null, null, 
false, Property.EMPTY_ARRAY)
+        }
+
+        @Override
+        void append(LogEvent event) {
+            if (event.message.formattedMessage.contains('Entity ECA 
definitions from')) {
+                count.incrementAndGet()
+            }
+        }
+
+        void reset() {
+            count.set(0)
+        }
+
+        int getMatchCount() {
+            count.get()
+        }
+
+    }
+
+    @Test
+    // SEPARATE_THREAD so a genuine hang fails at the deadline instead of 
blocking the build.
+    @Timeout(value = 60, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)
+    void concurrentCacheMissesDoNotEachRebuildIndependently() {
+        Log4jCoreLogger logger = (Log4jCoreLogger) 
LogManager.getLogger(EntityEcaUtil.name)
+        CountingAppender appender = new CountingAppender()
+        appender.start()
+        logger.addAppender(appender)
+        try {
+            // Warm-up rebuild: initializes EntityEcaUtil and measures how 
many resource-load log
+            // lines one full rebuild produces here (varies with which plugins 
are active).
+            EntityEcaUtil.getEntityEcaCache(READER_NAME)
+            int resourcesPerRebuild = appender.matchCount
+            Assertions.assertTrue(resourcesPerRebuild > 0,
+                    "Expected at least one entity ECA resource registered for 
reader \"${READER_NAME}\" in this environment")
+
+            UtilCache<String, ?> ecaReaderCache = 
UtilCache.findCache('entity.EcaReaders')
+            Assertions.assertNotNull(ecaReaderCache, 'entity.EcaReaders cache 
must already exist (created by EntityEcaUtil\'s static initializer)')
+
+            for (int iteration = 0; iteration < ITERATIONS; iteration++) {
+                ecaReaderCache.remove(READER_NAME)
+                appender.reset()
+
+                CountDownLatch startLatch = new CountDownLatch(1)
+                AtomicReference<Throwable> failure = new AtomicReference<>()
+                ThreadFactory daemonThreadFactory = { Runnable r ->
+                    Thread t = Executors.defaultThreadFactory().newThread(r)
+                    t.daemon = true
+                    t
+                } as ThreadFactory
+                ExecutorService racers = 
Executors.newFixedThreadPool(RACER_THREADS, daemonThreadFactory)
+                try {
+                    List<Future<Map>> futures = (1..RACER_THREADS).collect {
+                        racers.submit({
+                            startLatch.await()
+                            return EntityEcaUtil.getEntityEcaCache(READER_NAME)
+                        } as Callable<Map>)
+                    }
+
+                    startLatch.countDown()
+                    List<Map> results = futures.collect {
+                        try {
+                            it.get(30, TimeUnit.SECONDS)
+                        // Catching Throwable deliberately: any failure 
surfaced from a racer must fail the test.
+                        } catch (Throwable t) { // codenarc-disable 
CatchThrowable
+                            failure.compareAndSet(null, t)
+                            null
+                        }
+                    }
+
+                    if (failure.get() != null) {
+                        Assertions.fail("getEntityEcaCache() threw under 
concurrent load: ${failure.get()}", failure.get())
+                    }
+
+                    // Every racer must end up with the exact same published 
cache instance.
+                    Map first = results[0]
+                    Assertions.assertNotNull(first, 
"getEntityEcaCache(\"${READER_NAME}\") returned null")
+                    results.each {
+                        Assertions.assertSame(first, it,
+                                "All concurrent callers of 
getEntityEcaCache(\"${READER_NAME}\") must receive the same published cache 
instance")
+                    }
+                } finally {
+                    racers.shutdownNow()
+                    racers.awaitTermination(5, TimeUnit.SECONDS)
+                }
+
+                int rebuilds = appender.matchCount / resourcesPerRebuild
+                Assertions.assertEquals(0, appender.matchCount % 
resourcesPerRebuild,
+                        "Expected a whole number of full rebuilds (multiples 
of ${resourcesPerRebuild} resources), " +
+                                "got ${appender.matchCount} log lines")
+                Assertions.assertEquals(1, rebuilds,
+                        "Expected exactly 1 rebuild of the \"${READER_NAME}\" 
entity ECA cache across ${RACER_THREADS} " +
+                                "concurrent racers on iteration ${iteration}, 
but ${rebuilds} independent rebuilds happened " +
+                                '(redundant concurrent readConfig() calls - 
the check-then-act race)')
+            }
+        } finally {
+            logger.removeAppender(appender)
+            appender.stop()
+        }
+    }
+
+}

Reply via email to