ACS-11060 Use Guava in ParentAssocsCache (backport) (#3864)

This commit is contained in:
Damian Ujma
2026-02-27 10:25:52 +01:00
committed by GitHub
parent eece44cc05
commit 423133cdfa
7 changed files with 354 additions and 169 deletions
+4 -4
View File
@@ -1234,7 +1234,7 @@
"filename": "repository/src/main/resources/alfresco/repository.properties",
"hashed_secret": "a4a747bd4ba5e3a5049cad116881867c71fb625b",
"is_verified": false,
"line_number": 245,
"line_number": 249,
"is_secret": false
},
{
@@ -1242,7 +1242,7 @@
"filename": "repository/src/main/resources/alfresco/repository.properties",
"hashed_secret": "1459a56410378e4d3ab470eff570e5eae1742762",
"is_verified": false,
"line_number": 314,
"line_number": 318,
"is_secret": false
},
{
@@ -1250,7 +1250,7 @@
"filename": "repository/src/main/resources/alfresco/repository.properties",
"hashed_secret": "84551ae5442affc9f1a2d3b4c86ae8b24860149d",
"is_verified": false,
"line_number": 773,
"line_number": 777,
"is_secret": false
}
],
@@ -1845,5 +1845,5 @@
}
]
},
"generated_at": "2025-12-10T12:15:02Z"
"generated_at": "2026-02-25T12:24:46Z"
}
@@ -2,7 +2,7 @@
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2005 - 2023 Alfresco Software Limited
* Copyright (C) 2005 - 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
@@ -41,8 +41,6 @@ import java.util.Set;
import java.util.SortedSet;
import java.util.Stack;
import java.util.TreeSet;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -188,7 +186,8 @@ public abstract class AbstractNodeDAOImpl implements NodeDAO, BatchingDAO
*/
private ParentAssocsCache parentAssocsCache;
private int parentAssocsCacheSize;
private int parentAssocsCacheLimitFactor = 8;
private int parentAssocsCacheLimitFactor;
private int parentAssocsCacheConcurrencyLevel;
/**
* Cache for fast lookups of child nodes by <b>cm:name</b>.
@@ -399,6 +398,17 @@ public abstract class AbstractNodeDAOImpl implements NodeDAO, BatchingDAO
this.parentAssocsCacheLimitFactor = parentAssocsCacheLimitFactor;
}
/**
* Sets the concurrency level for the parent assocs cache.
*
* @param parentAssocsCacheConcurrencyLevel
* the parentAssocsCacheConcurrencyLevel to set
*/
public void setParentAssocsCacheConcurrencyLevel(int parentAssocsCacheConcurrencyLevel)
{
this.parentAssocsCacheConcurrencyLevel = parentAssocsCacheConcurrencyLevel;
}
/**
* Set the cache that maintains lookups by child <b>cm:name</b>
*
@@ -424,7 +434,7 @@ public abstract class AbstractNodeDAOImpl implements NodeDAO, BatchingDAO
PropertyCheck.mandatory(this, "usageDAO", usageDAO);
this.nodePropertyHelper = new NodePropertyHelper(dictionaryService, qnameDAO, localeDAO, contentDataDAO);
this.parentAssocsCache = new ParentAssocsCache(this.parentAssocsCacheSize, this.parentAssocsCacheLimitFactor);
this.parentAssocsCache = new ParentAssocsCache(this.parentAssocsCacheSize, this.parentAssocsCacheLimitFactor, this.parentAssocsCacheConcurrencyLevel);
}
/* Cache helpers */
@@ -4197,172 +4207,14 @@ public abstract class AbstractNodeDAOImpl implements NodeDAO, BatchingDAO
// done
}
/**
* A Map-like class for storing ParentAssocsInfos. It prunes its oldest ParentAssocsInfo entries not only when a capacity is reached, but also when a total number of cached parents is reached, as this is what dictates the overall memory usage.
*/
private static class ParentAssocsCache
{
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final int size;
private final int maxParentCount;
private final Map<Pair<Long, String>, ParentAssocsInfo> cache;
private final Map<Pair<Long, String>, Pair<Long, String>> nextKeys;
private final Map<Pair<Long, String>, Pair<Long, String>> previousKeys;
private Pair<Long, String> firstKey;
private Pair<Long, String> lastKey;
private int parentCount;
/**
* @param size
* int
* @param limitFactor
* int
*/
public ParentAssocsCache(int size, int limitFactor)
{
this.size = size;
this.maxParentCount = size * limitFactor;
final int mapSize = size * 2;
this.cache = new HashMap<Pair<Long, String>, ParentAssocsInfo>(mapSize);
this.nextKeys = new HashMap<Pair<Long, String>, Pair<Long, String>>(mapSize);
this.previousKeys = new HashMap<Pair<Long, String>, Pair<Long, String>>(mapSize);
}
private ParentAssocsInfo get(Pair<Long, String> cacheKey)
{
lock.readLock().lock();
try
{
return cache.get(cacheKey);
}
finally
{
lock.readLock().unlock();
}
}
private void put(Pair<Long, String> cacheKey, ParentAssocsInfo parentAssocs)
{
lock.writeLock().lock();
try
{
// If an entry already exists, remove it and do the necessary housekeeping
if (cache.containsKey(cacheKey))
{
remove(cacheKey);
}
// Add the value and prepend the key
cache.put(cacheKey, parentAssocs);
if (firstKey == null)
{
lastKey = cacheKey;
}
else
{
nextKeys.put(cacheKey, firstKey);
previousKeys.put(firstKey, cacheKey);
}
firstKey = cacheKey;
parentCount += parentAssocs.getParentAssocs().size();
// Now prune the oldest entries whilst we have more cache entries or cached parents than desired
int currentSize = cache.size();
while (currentSize > size || parentCount > maxParentCount)
{
remove(lastKey);
currentSize--;
}
}
finally
{
lock.writeLock().unlock();
}
}
private ParentAssocsInfo remove(Pair<Long, String> cacheKey)
{
lock.writeLock().lock();
try
{
// Remove from the map
ParentAssocsInfo oldParentAssocs = cache.remove(cacheKey);
// If the object didn't exist, we are done
if (oldParentAssocs == null)
{
return null;
}
// Re-link the list
Pair<Long, String> previousCacheKey = previousKeys.remove(cacheKey);
Pair<Long, String> nextCacheKey = nextKeys.remove(cacheKey);
if (nextCacheKey == null)
{
if (previousCacheKey == null)
{
firstKey = lastKey = null;
}
else
{
lastKey = previousCacheKey;
nextKeys.remove(previousCacheKey);
}
}
else
{
if (previousCacheKey == null)
{
firstKey = nextCacheKey;
previousKeys.remove(nextCacheKey);
}
else
{
nextKeys.put(previousCacheKey, nextCacheKey);
previousKeys.put(nextCacheKey, previousCacheKey);
}
}
// Update the parent count
parentCount -= oldParentAssocs.getParentAssocs().size();
return oldParentAssocs;
}
finally
{
lock.writeLock().unlock();
}
}
private void clear()
{
lock.writeLock().lock();
try
{
cache.clear();
nextKeys.clear();
previousKeys.clear();
firstKey = lastKey = null;
parentCount = 0;
}
finally
{
lock.writeLock().unlock();
}
}
}
/**
* @return Returns a node's parent associations
*/
private ParentAssocsInfo getParentAssocsCached(Long nodeId)
{
Node node = getNodeNotNull(nodeId, false);
Pair<Long, String> cacheKey = new Pair<Long, String>(nodeId, node.getTransaction().getChangeTxnId());
ParentAssocsInfo value = parentAssocsCache.get(cacheKey);
if (value == null)
{
value = loadParentAssocs(node.getNodeVersionKey());
parentAssocsCache.put(cacheKey, value);
}
Pair<Long, String> cacheKey = new Pair<>(nodeId, node.getTransaction().getChangeTxnId());
ParentAssocsInfo value = parentAssocsCache.get(cacheKey, () -> loadParentAssocs(node.getNodeVersionKey()));
// We have already validated on loading that we have a list in sync with the child node, so if the list is still
// empty we have an integrity problem
@@ -0,0 +1,106 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.domain.node;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.util.Pair;
/**
* A Map-like class for storing ParentAssocsInfos, backed by a Google {@link Cache} implementation.
*/
class ParentAssocsCache
{
private final Cache<Pair<Long, String>, ParentAssocsInfo> cache;
/**
* @param size
* int
* @param limitFactor
* int
* @param concurrencyLevel
* int
*/
ParentAssocsCache(int size, int limitFactor, int concurrencyLevel)
{
final int maxParentCount = size * limitFactor;
this.cache = CacheBuilder.newBuilder()
.maximumWeight(maxParentCount)
.concurrencyLevel(concurrencyLevel)
.weigher((Pair<Long, String> key, ParentAssocsInfo value) -> {
var parentAssocsSize = Optional.ofNullable(value)
.map(ParentAssocsInfo::getParentAssocs)
.map(Map::size)
.orElse(1);
return Math.max(1, parentAssocsSize);
})
.build();
}
ParentAssocsInfo get(Pair<Long, String> cacheKey)
{
return cache.getIfPresent(cacheKey);
}
ParentAssocsInfo get(Pair<Long, String> cacheKey, Callable<ParentAssocsInfo> valueLoader)
{
try
{
return cache.get(cacheKey, valueLoader);
}
catch (Exception e)
{
throw new AlfrescoRuntimeException("Failed to load parent associations", e);
}
}
void put(Pair<Long, String> cacheKey, ParentAssocsInfo parentAssocs)
{
cache.put(cacheKey, parentAssocs);
}
ParentAssocsInfo remove(Pair<Long, String> cacheKey)
{
ParentAssocsInfo old = cache.getIfPresent(cacheKey);
if (old != null)
{
cache.invalidate(cacheKey);
}
return old;
}
void clear()
{
cache.invalidateAll();
}
}
@@ -138,6 +138,7 @@
<property name="propertiesCache" ref="node.propertiesCache"/>
<property name="parentAssocsCacheSize" value="${system.cache.parentAssocs.maxSize}"/>
<property name="parentAssocsCacheLimitFactor" value="${system.cache.parentAssocs.limitFactor}"/>
<property name="parentAssocsCacheConcurrencyLevel" value="${system.cache.parentAssocs.concurrencyLevel}"/>
<property name="childByNameCache" ref="node.childByNameCache"/>
<property name="cachingThreshold" value="${nodes.bulkLoad.cachingThreshold}"/>
</bean>
@@ -147,6 +147,10 @@ system.cache.parentAssocs.maxSize=130000
# memory usage.
system.cache.parentAssocs.limitFactor=8
# Number of concurrent update threads.
# Higher values reduce write contention but may increase memory usage.
system.cache.parentAssocs.concurrencyLevel=4
#
# Properties to limit resources spent on individual searches
#
@@ -54,6 +54,7 @@ import org.alfresco.util.testing.category.NonBuildTests;
// From AppContext05TestSuite
org.alfresco.repo.domain.node.NodeDAOTest.class,
org.alfresco.repo.domain.node.ParentAssocsCacheTest.class,
org.alfresco.repo.domain.subscriptions.SubscriptionDAOTest.class,
org.alfresco.repo.security.permissions.impl.AclDaoComponentTest.class,
org.alfresco.repo.domain.contentdata.ContentDataDAOTest.class,
@@ -0,0 +1,221 @@
/*
* #%L
* Alfresco Repository
* %%
* Copyright (C) 2026 Alfresco Software Limited
* %%
* This file is part of the Alfresco software.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* Alfresco is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Alfresco is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
* #L%
*/
package org.alfresco.repo.domain.node;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
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.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import org.junit.Test;
import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.util.Pair;
public class ParentAssocsCacheTest
{
@Test
public void testGetReturnsNullWhenAbsent()
{
ParentAssocsCache cache = new ParentAssocsCache(10, 2, 1);
Pair<Long, String> key = new Pair<>(1L, "store");
assertNull(cache.get(key));
}
@Test
public void testGetWithLoaderCachesValue()
{
ParentAssocsCache cache = new ParentAssocsCache(10, 2, 1);
Pair<Long, String> key = new Pair<>(2L, "store");
ParentAssocsInfo info = parentAssocsInfo(10L, true);
AtomicInteger calls = new AtomicInteger();
Callable<ParentAssocsInfo> loader = () -> {
calls.incrementAndGet();
return info;
};
ParentAssocsInfo first = cache.get(key, loader);
ParentAssocsInfo second = cache.get(key, loader);
assertSame(info, first);
assertSame(info, second);
assertSame(info, cache.get(key));
assertEquals(1, calls.get());
}
@Test
public void testGetWrapsExecutionException()
{
ParentAssocsCache cache = new ParentAssocsCache(10, 2, 1);
Pair<Long, String> key = new Pair<>(3L, "store");
RuntimeException root = new RuntimeException("boom");
Callable<ParentAssocsInfo> loader = () -> {
throw root;
};
AlfrescoRuntimeException ex = assertThrows(AlfrescoRuntimeException.class, () -> cache.get(key, loader));
assertNotNull(ex.getCause());
}
@Test
public void testPutAndRemoveReturnOld()
{
ParentAssocsCache cache = new ParentAssocsCache(10, 2, 1);
Pair<Long, String> key = new Pair<>(4L, "store");
ParentAssocsInfo info = parentAssocsInfo(20L, false);
cache.put(key, info);
ParentAssocsInfo removed = cache.remove(key);
assertSame(info, removed);
assertNull(cache.get(key));
}
@Test
public void testClearInvalidatesAll()
{
ParentAssocsCache cache = new ParentAssocsCache(10, 2, 1);
Pair<Long, String> key1 = new Pair<>(5L, "store");
Pair<Long, String> key2 = new Pair<>(6L, "store");
cache.put(key1, parentAssocsInfo(30L, true));
cache.put(key2, parentAssocsInfo(31L, false));
cache.clear();
assertNull(cache.get(key1));
assertNull(cache.get(key2));
}
@Test
public void testConcurrentGetWithLoaderOnlyLoadsOnce() throws Exception
{
ParentAssocsCache cache = new ParentAssocsCache(10, 2, 1);
Pair<Long, String> key = new Pair<>(7L, "store");
ParentAssocsInfo info = parentAssocsInfo(40L, true);
AtomicInteger calls = new AtomicInteger();
Callable<ParentAssocsInfo> loader = () -> {
calls.incrementAndGet();
return info;
};
int threads = 24;
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newCachedThreadPool();
try
{
List<Future<ParentAssocsInfo>> futures = new ArrayList<>(threads);
for (int i = 0; i < threads; i++)
{
futures.add(executor.submit(() -> {
start.await();
return cache.get(key, loader);
}));
}
start.countDown();
for (Future<ParentAssocsInfo> future : futures)
{
assertSame(info, future.get(5, TimeUnit.SECONDS));
}
assertEquals(1, calls.get());
}
finally
{
executor.shutdownNow();
}
}
@Test
public void testConcurrentGetDifferentKeysLoadsEachOnce() throws Exception
{
ParentAssocsCache cache = new ParentAssocsCache(50, 2, 1);
int keys = 16;
List<Pair<Long, String>> keyList = IntStream.range(0, keys)
.mapToObj(index -> new Pair<>((long) index + 100, "store"))
.toList();
List<AtomicInteger> counters = IntStream.range(0, keys)
.mapToObj(index -> new AtomicInteger())
.toList();
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newCachedThreadPool();
try
{
List<Future<ParentAssocsInfo>> futures = new ArrayList<>(keys);
for (int i = 0; i < keys; i++)
{
int index = i;
Pair<Long, String> key = keyList.get(i);
ParentAssocsInfo info = parentAssocsInfo((long) index + 200, index % 2 == 0);
Callable<ParentAssocsInfo> loader = () -> {
counters.get(index).incrementAndGet();
return info;
};
futures.add(executor.submit(() -> {
start.await();
return cache.get(key, loader);
}));
}
start.countDown();
for (int i = 0; i < keys; i++)
{
ParentAssocsInfo value = futures.get(i).get(5, TimeUnit.SECONDS);
assertSame(cache.get(keyList.get(i)), value);
}
assertTrue(counters.stream().allMatch(counter -> counter.get() == 1));
}
finally
{
executor.shutdownNow();
}
}
private ParentAssocsInfo parentAssocsInfo(Long assocId, boolean isPrimary)
{
return new ParentAssocsInfo(false, false, parentAssocEntities(assocId, isPrimary));
}
private List<ChildAssocEntity> parentAssocEntities(Long assocId, boolean isPrimary)
{
ChildAssocEntity assoc = new ChildAssocEntity();
assoc.setId(assocId);
assoc.setPrimary(isPrimary);
return Arrays.asList(assoc);
}
}