mirror of
https://github.com/Alfresco/alfresco-community-repo.git
synced 2026-09-16 18:13:17 +00:00
ACS-11473 Switch nodesSharedCache to invalidating and fix stale cache in rendition (#4278)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* #%L
|
||||
* Alfresco Repository
|
||||
* %%
|
||||
* Copyright (C) 2005 - 2025 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
|
||||
@@ -48,6 +48,7 @@ import org.alfresco.model.RenditionModel;
|
||||
import org.alfresco.repo.content.ContentServicePolicies;
|
||||
import org.alfresco.repo.content.MimetypeMap;
|
||||
import org.alfresco.repo.content.metadata.AsynchronousExtractor;
|
||||
import org.alfresco.repo.domain.node.NodeDAO;
|
||||
import org.alfresco.repo.policy.BehaviourFilter;
|
||||
import org.alfresco.repo.policy.PolicyComponent;
|
||||
import org.alfresco.repo.rendition.RenditionPreventionRegistry;
|
||||
@@ -96,6 +97,22 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
|
||||
private static Log logger = LogFactory.getLog(RenditionService2Impl.class);
|
||||
|
||||
private TransactionService transactionService;
|
||||
private NodeService nodeService;
|
||||
private NodeDAO nodeDAO;
|
||||
private ContentService contentService;
|
||||
private RenditionPreventionRegistry renditionPreventionRegistry;
|
||||
private RenditionDefinitionRegistry2 renditionDefinitionRegistry2;
|
||||
private TransformClient transformClient;
|
||||
private PolicyComponent policyComponent;
|
||||
private BehaviourFilter behaviourFilter;
|
||||
private RuleService ruleService;
|
||||
private PostTxnCallbackScheduler renditionRequestSheduler;
|
||||
private TransformReplyProvider transformReplyProvider;
|
||||
private AsynchronousExtractor asynchronousExtractor;
|
||||
private boolean enabled;
|
||||
private boolean thumbnailsEnabled;
|
||||
|
||||
// As Async transforms and renditions are so similar, this class provides a way to provide the code that is different.
|
||||
private abstract static class RenderOrTransformCallBack
|
||||
{
|
||||
@@ -110,21 +127,6 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
{}
|
||||
}
|
||||
|
||||
private TransactionService transactionService;
|
||||
private NodeService nodeService;
|
||||
private ContentService contentService;
|
||||
private RenditionPreventionRegistry renditionPreventionRegistry;
|
||||
private RenditionDefinitionRegistry2 renditionDefinitionRegistry2;
|
||||
private TransformClient transformClient;
|
||||
private PolicyComponent policyComponent;
|
||||
private BehaviourFilter behaviourFilter;
|
||||
private RuleService ruleService;
|
||||
private PostTxnCallbackScheduler renditionRequestSheduler;
|
||||
private TransformReplyProvider transformReplyProvider;
|
||||
private AsynchronousExtractor asynchronousExtractor;
|
||||
private boolean enabled;
|
||||
private boolean thumbnailsEnabled;
|
||||
|
||||
public void setTransactionService(TransactionService transactionService)
|
||||
{
|
||||
this.transactionService = transactionService;
|
||||
@@ -135,6 +137,11 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
this.nodeService = nodeService;
|
||||
}
|
||||
|
||||
public void setNodeDAO(NodeDAO nodeDAO)
|
||||
{
|
||||
this.nodeDAO = nodeDAO;
|
||||
}
|
||||
|
||||
public void setContentService(ContentService contentService)
|
||||
{
|
||||
this.contentService = contentService;
|
||||
@@ -218,6 +225,7 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
PropertyCheck.mandatory(this, "policyComponent", policyComponent);
|
||||
PropertyCheck.mandatory(this, "behaviourFilter", behaviourFilter);
|
||||
PropertyCheck.mandatory(this, "ruleService", ruleService);
|
||||
PropertyCheck.mandatory(this, "nodeDAO", nodeDAO);
|
||||
PropertyCheck.mandatory(this, "asynchronousExtractor", asynchronousExtractor);
|
||||
}
|
||||
|
||||
@@ -334,7 +342,8 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
|
||||
String user = AuthenticationUtil.getRunAsUser();
|
||||
RetryingTransactionHelper.RetryingTransactionCallback callback = () -> {
|
||||
int sourceContentHashCode = getSourceContentHashCode(sourceNodeRef);
|
||||
ContentData sourceContentData = getSourceContentData(sourceNodeRef);
|
||||
int sourceContentHashCode = getSourceContentHashCode(sourceContentData);
|
||||
if (!supported.get())
|
||||
{
|
||||
if (logger.isDebugEnabled())
|
||||
@@ -350,6 +359,19 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
|
||||
if (sourceContentHashCode != SOURCE_HAS_NO_CONTENT)
|
||||
{
|
||||
if (sourceContentData.getSize() == 0)
|
||||
{
|
||||
// In a cluster with invalidating cache, the shared cache may hold stale
|
||||
// ContentData (size=0) because cache invalidation from the uploading node
|
||||
// hasn't propagated yet. Bypass the shared cache to read directly from DB.
|
||||
nodeDAO.setCheckNodeConsistency();
|
||||
sourceContentData = getSourceContentData(sourceNodeRef);
|
||||
if (sourceContentData != null && sourceContentData.getSize() > 0)
|
||||
{
|
||||
sourceContentHashCode = getSourceContentHashCode(sourceContentData);
|
||||
renderOrTransform.throwIllegalStateExceptionIfAlreadyDone(sourceContentHashCode);
|
||||
}
|
||||
}
|
||||
transformClient.transform(sourceNodeRef, renditionDefinition, user, sourceContentHashCode);
|
||||
}
|
||||
else
|
||||
@@ -663,13 +685,15 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hash code of the source node's content url. As transformations may be returned in a different sequences to which they were requested, this is used work out if a rendition should be replaced.
|
||||
*/
|
||||
private int getSourceContentHashCode(NodeRef sourceNodeRef)
|
||||
private ContentData getSourceContentData(NodeRef sourceNodeRef)
|
||||
{
|
||||
return DefaultTypeConverter.INSTANCE.convert(ContentData.class,
|
||||
nodeService.getProperty(sourceNodeRef, PROP_CONTENT));
|
||||
}
|
||||
|
||||
private int getSourceContentHashCode(ContentData contentData)
|
||||
{
|
||||
int hashCode = SOURCE_HAS_NO_CONTENT;
|
||||
ContentData contentData = DefaultTypeConverter.INSTANCE.convert(ContentData.class, nodeService.getProperty(sourceNodeRef, PROP_CONTENT));
|
||||
if (contentData != null)
|
||||
{
|
||||
// Originally we used the contentData URL, but that is not enough if the mimetype changes.
|
||||
@@ -682,6 +706,14 @@ public class RenditionService2Impl implements RenditionService2, InitializingBea
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hash code of the source node's content url. As transformations may be returned in a different sequences to which they were requested, this is used work out if a rendition should be replaced.
|
||||
*/
|
||||
private int getSourceContentHashCode(NodeRef sourceNodeRef)
|
||||
{
|
||||
return getSourceContentHashCode(getSourceContentData(sourceNodeRef));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hash code of source node's content url on the rendition node (node may be null) if it does not exist. Used work out if a rendition should be replaced. {@code -2} is returned if the rendition does not exist or was not created by RenditionService2. {@code -1} is returned if there was no source content or the rendition failed.
|
||||
*/
|
||||
|
||||
@@ -164,7 +164,7 @@ cache.node.nodesSharedCache.tx.statsEnabled=${caches.tx.statsEnabled}
|
||||
cache.node.nodesSharedCache.maxItems=250000
|
||||
cache.node.nodesSharedCache.timeToLiveSeconds=300
|
||||
cache.node.nodesSharedCache.maxIdleSeconds=0
|
||||
cache.node.nodesSharedCache.cluster.type=fully-distributed
|
||||
cache.node.nodesSharedCache.cluster.type=invalidating
|
||||
cache.node.nodesSharedCache.backup-count=1
|
||||
cache.node.nodesSharedCache.eviction-policy=LRU
|
||||
cache.node.nodesSharedCache.merge-policy=com.hazelcast.spi.merge.PutIfAbsentMergePolicy
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<bean id="renditionService2" class="org.alfresco.repo.rendition2.RenditionService2Impl">
|
||||
<property name="transactionService" ref="TransactionService" />
|
||||
<property name="nodeService" ref="NodeService" />
|
||||
<property name="nodeDAO" ref="nodeDAO" />
|
||||
<property name="contentService" ref="ContentService" />
|
||||
<property name="renditionPreventionRegistry" ref="renditionPreventionRegistry" />
|
||||
<property name="transformClient" ref="transformClient" />
|
||||
|
||||
@@ -27,6 +27,7 @@ package org.alfresco.repo.rendition2;
|
||||
|
||||
import static junit.framework.TestCase.assertTrue;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
@@ -38,6 +39,7 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Before;
|
||||
@@ -48,6 +50,7 @@ import org.mockito.junit.MockitoJUnitRunner;
|
||||
|
||||
import org.alfresco.model.ContentModel;
|
||||
import org.alfresco.repo.content.metadata.AsynchronousExtractor;
|
||||
import org.alfresco.repo.domain.node.NodeDAO;
|
||||
import org.alfresco.repo.policy.BehaviourFilter;
|
||||
import org.alfresco.repo.policy.PolicyComponent;
|
||||
import org.alfresco.repo.rendition.RenditionPreventionRegistry;
|
||||
@@ -84,6 +87,8 @@ public class RenditionService2Test
|
||||
@Mock
|
||||
private NodeService nodeService;
|
||||
@Mock
|
||||
private NodeDAO nodeDAO;
|
||||
@Mock
|
||||
private ContentService contentService;
|
||||
@Mock
|
||||
private RenditionPreventionRegistry renditionPreventionRegistry;
|
||||
@@ -135,6 +140,7 @@ public class RenditionService2Test
|
||||
when(nodeService.exists(nodeRefMissing)).thenReturn(false);
|
||||
when(nodeService.getProperty(nodeRef, ContentModel.PROP_CONTENT)).thenReturn(contentData);
|
||||
when(contentData.getContentUrl()).thenReturn(contentUrl);
|
||||
when(contentData.getSize()).thenReturn(1024L);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
Object[] args = invocation.getArguments();
|
||||
@@ -151,6 +157,7 @@ public class RenditionService2Test
|
||||
|
||||
renditionService2.setTransactionService(transactionService);
|
||||
renditionService2.setNodeService(nodeService);
|
||||
renditionService2.setNodeDAO(nodeDAO);
|
||||
renditionService2.setContentService(contentService);
|
||||
renditionService2.setRenditionPreventionRegistry(renditionPreventionRegistry);
|
||||
renditionService2.setRenditionDefinitionRegistry2(renditionDefinitionRegistry2);
|
||||
@@ -293,4 +300,45 @@ public class RenditionService2Test
|
||||
String resizeWidth = doclib.getTransformOptions().get("resizeWidth");
|
||||
assertEquals("doclib has not been overridden", "180", resizeWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* In a cluster with invalidating cache, the rendition callback reads stale ContentData (size=0) because cache invalidation hasn't propagated. The fix detects size=0, calls nodeDAO.setCheckNodeConsistency() to bypass the shared cache, and re-reads from DB. This test simulates the stale cache scenario where the re-read after bypass returns fresh data.
|
||||
*/
|
||||
@Test
|
||||
public void staleCacheBypassSucceedsWithFreshDataFromDB()
|
||||
{
|
||||
ContentData staleContentData = new ContentData("store://stale-url", "application/pdf", 0L, "UTF-8");
|
||||
ContentData freshContentData = new ContentData("store://fresh-url", "application/pdf", 4726L, "UTF-8");
|
||||
|
||||
AtomicBoolean cacheBypassEnabled = new AtomicBoolean(false);
|
||||
doAnswer(inv -> {
|
||||
cacheBypassEnabled.set(true);
|
||||
return null;
|
||||
}).when(nodeDAO).setCheckNodeConsistency();
|
||||
|
||||
when(nodeService.getProperty(nodeRef, ContentModel.PROP_CONTENT)).thenAnswer(invocation -> cacheBypassEnabled.get() ? freshContentData : staleContentData);
|
||||
|
||||
renditionService2.render(nodeRef, TEST_RENDITION);
|
||||
|
||||
verify(nodeDAO).setCheckNodeConsistency();
|
||||
verify(transformClient, times(1)).transform(any(), any(), nullable(String.class), anyInt());
|
||||
assertFalse("failure() should NOT be called when DB re-read returns valid content", failureCalled);
|
||||
}
|
||||
|
||||
/**
|
||||
* When content is genuinely 0 bytes (not a stale cache issue), the transform is still attempted (preserving original behavior — downstream validation handles the rejection).
|
||||
*/
|
||||
@Test
|
||||
public void genuineEmptyContentStillAttemptsTransform()
|
||||
{
|
||||
ContentData emptyContentData = new ContentData("store://empty-url", "application/pdf", 0L, "UTF-8");
|
||||
|
||||
// Always return 0 size — this is genuinely empty content, not stale cache
|
||||
when(nodeService.getProperty(nodeRef, ContentModel.PROP_CONTENT)).thenReturn(emptyContentData);
|
||||
|
||||
renditionService2.render(nodeRef, TEST_RENDITION);
|
||||
|
||||
verify(nodeDAO).setCheckNodeConsistency();
|
||||
verify(transformClient, times(1)).transform(any(), any(), nullable(String.class), anyInt());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user