diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/CacheConflictHandler.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/CacheConflictHandler.java
new file mode 100644
index 00000000000..4122c1b0704
--- /dev/null
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/CacheConflictHandler.java
@@ -0,0 +1,95 @@
+/*
+ * 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.jackrabbit.oak.security.user;
+
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.plugins.commit.DefaultThreeWayConflictHandler;
+import org.apache.jackrabbit.oak.plugins.memory.PropertyBuilder;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@code CacheConflictHandler} takes care of merging the {@code rep:expiration} property
+ * during parallel updates.
+ *
+ * The conflict handler deals with the following conflicts:
+ *
+ * - {@code addExistingProperty} : {@code Resolution.IGNORED}, We should not have add conflints, since the {@code rep:{@code rep:expiration}} node is created with the user
+ * - {@code changeDeletedProperty}: {@code Resolution.IGNORED},
+ * - {@code changeChangedProperty}: {@code Resolution.MERGED}, the properties with higher {@code rep:expiration} get merged
+ * - {@code deleteChangedProperty}: {@code Resolution.IGNORED} .
+ * - {@code deleteDeletedProperty}: {@code Resolution.IGNORED}.
+ * - {@code changeDeletedNode} : {@code Resolution.IGNORED}, .
+ * - {@code deleteChangedNode} : {@code Resolution.IGNORED},
+ * - {@code deleteDeletedNode} : {@code Resolution.IGNORED}.
+ *
+ */
+
+class CacheConflictHandler extends DefaultThreeWayConflictHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CacheConflictHandler.class);
+
+ protected CacheConflictHandler() {
+ super(Resolution.IGNORED);
+
+ }
+
+ private Resolution resolveRepExpirationConflict(@NotNull NodeBuilder parent, @NotNull PropertyState ours, @NotNull PropertyState theirs,
+ PropertyState base) {
+ if (CacheConstants.REP_EXPIRATION.equals(ours.getName()) && CacheConstants.REP_EXPIRATION.equals(theirs.getName())){
+
+ PropertyBuilder merged = PropertyBuilder.scalar(Type.LONG);
+ merged.setName(CacheConstants.REP_EXPIRATION);
+
+ //if base is bigger than ours and theirs, then use base. This should never happens
+ if (base != null &&
+ base.getValue(Type.LONG) > ours.getValue(Type.LONG) &&
+ base.getValue(Type.LONG) > theirs.getValue(Type.LONG)){
+ merged.setValue(base.getValue(Type.LONG));
+ LOG.warn("base is bigger than ours and theirs. This was supposed to never happens");
+ return Resolution.MERGED;
+ }
+
+ //if ours is bigger than theirs, then use ours
+ //otherwise use theirs
+ if (ours.getValue(Type.LONG) > theirs.getValue(Type.LONG)){
+ merged.setValue(ours.getValue(Type.LONG));
+ } else {
+ merged.setValue(theirs.getValue(Type.LONG));
+ }
+ parent.setProperty(merged.getPropertyState());
+ LOG.debug("Resolved conflict for property {} our value: {}, their value {}, merged value: {}", CacheConstants.REP_EXPIRATION, ours.getValue(Type.LONG), theirs.getValue(Type.LONG), merged.getValue(0));
+ return Resolution.MERGED;
+ }
+ return Resolution.IGNORED;
+
+ }
+
+ @Override
+ public Resolution changeChangedProperty(@NotNull NodeBuilder parent, @NotNull PropertyState ours, @NotNull PropertyState theirs,
+ @NotNull PropertyState base) {
+
+ return resolveRepExpirationConflict(parent, ours, theirs, base);
+ }
+
+
+}
diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java
index 06696dc4a64..84e3826ff21 100644
--- a/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java
+++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImpl.java
@@ -263,7 +263,7 @@ public List extends ValidatorProvider> getValidators(@NotNull String workspace
@NotNull
@Override
public List getConflictHandlers() {
- return ImmutableList.of(new RepMembersConflictHandler());
+ return ImmutableList.of(new RepMembersConflictHandler(), new CacheConflictHandler());
}
@NotNull
diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/CacheConflictHandlerTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/CacheConflictHandlerTest.java
new file mode 100644
index 00000000000..d32bf4813de
--- /dev/null
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/CacheConflictHandlerTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.jackrabbit.oak.security.user;
+
+import org.apache.jackrabbit.api.security.user.Group;
+import org.apache.jackrabbit.oak.AbstractSecurityTest;
+import org.apache.jackrabbit.oak.api.ContentSession;
+import org.apache.jackrabbit.oak.api.PropertyState;
+import org.apache.jackrabbit.oak.api.Root;
+import org.apache.jackrabbit.oak.api.Tree;
+import org.apache.jackrabbit.oak.api.Type;
+import org.apache.jackrabbit.oak.spi.security.ConfigurationParameters;
+import org.apache.jackrabbit.oak.spi.security.authentication.SystemSubject;
+import org.apache.jackrabbit.oak.spi.security.principal.PrincipalConfiguration;
+import org.apache.jackrabbit.oak.spi.security.principal.PrincipalProvider;
+import org.apache.jackrabbit.oak.spi.security.user.UserConfiguration;
+import org.apache.jackrabbit.oak.spi.state.NodeBuilder;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Test;
+
+import javax.security.auth.Subject;
+import java.security.Principal;
+import java.security.PrivilegedExceptionAction;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.apache.jackrabbit.oak.security.user.CacheConstants.REP_EXPIRATION;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class CacheConflictHandlerTest extends AbstractSecurityTest {
+
+ static final String PARAM_CACHE_EXPIRATION = "cacheExpiration";
+
+ @Override
+ public void before() throws Exception {
+ super.before();
+
+ String groupId = "testGroup" + UUID.randomUUID();
+ @NotNull Group testGroup = getUserManager(root).createGroup(groupId);
+ testGroup.addMember(getTestUser());
+
+ String groupId2 = "testGroup" + UUID.randomUUID() + "2";
+ @NotNull Group testGroup2 = getUserManager(root).createGroup(groupId2);
+ testGroup.addMember(testGroup2);
+
+ String groupId3 = "testGroup" + UUID.randomUUID() + "3";
+ @NotNull Group testGroup3 = getUserManager(root).createGroup(groupId3);
+
+ root.commit();
+ }
+
+ private Tree getCacheTree(Root root) throws Exception {
+ return getCacheTree(root, getTestUser().getPath());
+ }
+
+ private Tree getCacheTree(Root root, String authorizablePath) {
+ return root.getTree(authorizablePath + '/' + CacheConstants.REP_CACHE);
+ }
+
+ @Override
+ protected ConfigurationParameters getSecurityConfigParameters() {
+ return ConfigurationParameters.of(
+ UserConfiguration.NAME,
+ ConfigurationParameters.of(PARAM_CACHE_EXPIRATION, 3600 * 1000)
+ );
+ }
+
+ @Test
+ public void testChangeChangedPropertyLower() throws Exception {
+
+ PrincipalConfiguration pc = getConfig(PrincipalConfiguration.class);
+
+ Root oursRoot = Subject.doAs(SystemSubject.INSTANCE, (PrivilegedExceptionAction) () -> login(null)).getLatestRoot();
+ Root theirsRoot = Subject.doAs(SystemSubject.INSTANCE, (PrivilegedExceptionAction) () -> login(null)).getLatestRoot();
+
+ PrincipalProvider oursPP = pc.getPrincipalProvider(oursRoot, namePathMapper);
+ PrincipalProvider theirsPP = pc.getPrincipalProvider(theirsRoot, namePathMapper);
+
+ // set of principals that read from user + membership-provider -> cache being filled
+ oursPP.getPrincipals(getTestUser().getID());
+ assertTrue(getCacheTree(oursRoot).exists());
+ getCacheTree(oursRoot).getProperty("rep:expiration").getValue(Type.LONG).longValue();
+
+ theirsPP.getPrincipals(getTestUser().getID());
+ assertTrue(getCacheTree(theirsRoot).exists());
+ long theirExpiration = getCacheTree(theirsRoot).getProperty("rep:expiration").getValue(Type.LONG).longValue();
+
+
+ Tree ourCache = getCacheTree(oursRoot);
+ ourCache.setProperty(REP_EXPIRATION, 2);
+ oursRoot.commit(CacheValidatorProvider.asCommitAttributes());
+
+ root.commit();
+ assertEquals(getCacheTree(root).getProperty(REP_EXPIRATION).getValue(Type.LONG).longValue(), theirExpiration);
+
+ }
+
+ @Test
+ public void testChangeChangedPropertyHigher() throws Exception {
+
+ PrincipalConfiguration pc = getConfig(PrincipalConfiguration.class);
+
+ Root oursRoot = Subject.doAs(SystemSubject.INSTANCE, (PrivilegedExceptionAction) () -> login(null)).getLatestRoot();
+ Root theirsRoot = Subject.doAs(SystemSubject.INSTANCE, (PrivilegedExceptionAction) () -> login(null)).getLatestRoot();
+
+ PrincipalProvider oursPP = pc.getPrincipalProvider(oursRoot, namePathMapper);
+ PrincipalProvider theirsPP = pc.getPrincipalProvider(theirsRoot, namePathMapper);
+
+ // set of principals that read from user + membership-provider -> cache being filled
+ Set extends Principal> ourPrincipals = oursPP.getPrincipals(getTestUser().getID());
+ assertTrue(getCacheTree(oursRoot).exists());
+ getCacheTree(oursRoot).getProperty("rep:expiration").getValue(Type.LONG).longValue();
+
+ Set extends Principal> theirPrincipals = theirsPP.getPrincipals(getTestUser().getID());
+ assertTrue(getCacheTree(theirsRoot).exists());
+ long theirExpiration = getCacheTree(theirsRoot).getProperty("rep:expiration").getValue(Type.LONG).longValue();
+
+
+ Tree ourCache = getCacheTree(oursRoot);
+ ourCache.setProperty(REP_EXPIRATION, theirExpiration + 1000);
+ oursRoot.commit(CacheValidatorProvider.asCommitAttributes());
+
+ root.commit();
+ assertEquals(getCacheTree(root).getProperty(REP_EXPIRATION).getValue(Type.LONG).longValue(), theirExpiration + 1000);
+
+ }
+
+ @Test
+ public void testChangeChangedPropertyBaseHigher() {
+ NodeBuilder parent = mock(NodeBuilder.class);
+
+ PropertyState ours = mock(PropertyState.class);
+ PropertyState base = mock(PropertyState.class);
+ PropertyState theirs = mock(PropertyState.class);
+
+ when(ours.getName()).thenReturn(REP_EXPIRATION);
+ when(base.getName()).thenReturn(REP_EXPIRATION);
+ when(theirs.getName()).thenReturn(REP_EXPIRATION);
+
+ when(ours.getValue(Type.LONG)).thenReturn(1000L);
+ when(base.getValue(Type.LONG)).thenReturn(2000L);
+ when(theirs.getValue(Type.LONG)).thenReturn(900L);
+
+ CacheConflictHandler handler = new CacheConflictHandler();
+ assertEquals(CacheConflictHandler.Resolution.MERGED, handler.changeChangedProperty(parent, ours, theirs, base));
+
+ }
+
+ @Test
+ public void testChangeChangedPropertyIgnore() {
+ NodeBuilder parent = mock(NodeBuilder.class);
+
+ PropertyState ours = mock(PropertyState.class);
+ PropertyState base = mock(PropertyState.class);
+ PropertyState theirs = mock(PropertyState.class);
+
+ CacheConflictHandler handler = new CacheConflictHandler();
+ assertEquals(CacheConflictHandler.Resolution.IGNORED, handler.changeChangedProperty(parent, ours, theirs, base));
+
+ }
+}
\ No newline at end of file
diff --git a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java
index d026cf5440a..f08789c99d4 100644
--- a/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java
+++ b/oak-core/src/test/java/org/apache/jackrabbit/oak/security/user/UserConfigurationImplTest.java
@@ -83,7 +83,7 @@ public void testGetConflictHandlers() {
UserConfigurationImpl configuration = new UserConfigurationImpl();
List conflictHandlers = configuration.getConflictHandlers();
- assertEquals(1, conflictHandlers.size());
+ assertEquals(2, conflictHandlers.size());
assertTrue(conflictHandlers.get(0) instanceof RepMembersConflictHandler);
}