This is an automated email from the ASF dual-hosted git repository. thomasm pushed a commit to branch OAK-11766 in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git
commit 97f1117525cc7c4e022a905d2d374ab98bc90731 Author: Thomas Mueller <[email protected]> AuthorDate: Wed Jun 18 12:06:36 2025 +0200 OAK-11766 Write Throttling Mechanism - Session.save() delay --- .../oak/api/jmx/RepositoryManagementMBean.java | 16 ++ .../jackrabbit/oak/api/jmx/package-info.java | 2 +- .../oak/management/RepositoryManager.java | 11 + .../oak/jcr/delegate/SessionDelegate.java | 10 +- .../oak/jcr/repository/RepositoryImpl.java | 6 +- .../oak/jcr/session/SessionSaveDelayer.java | 163 +++++++++++++ .../oak/jcr/session/SessionSaveDelayerConfig.java | 204 ++++++++++++++++ .../oak/jcr/delegate/AbstractDelegatorTest.java | 4 +- .../jcr/session/SessionSaveDelayerConfigTest.java | 257 +++++++++++++++++++++ .../oak/jcr/session/SessionSaveDelayerTest.java | 238 +++++++++++++++++++ 10 files changed, 907 insertions(+), 4 deletions(-) diff --git a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/RepositoryManagementMBean.java b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/RepositoryManagementMBean.java index 9c879ef4e3..ae326dbc69 100644 --- a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/RepositoryManagementMBean.java +++ b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/RepositoryManagementMBean.java @@ -263,4 +263,20 @@ public interface RepositoryManagementMBean { @Description("Refresh all currently open sessions") TabularData refreshAllSessions(); + /** + * Get the Session.save() delay configuration. + * + * @return the configuration + */ + @Description("The Session.save() delay configuration") + String getSessionSaveDelayerConfig(); + + /** + * Set the Session.save() delay configuration. + * + * @param config the new configuration + */ + @Description("The Session.save() delay configuration") + void setSessionSaveDelayerConfig(String config); + } diff --git a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java index 678163b257..47361eda97 100644 --- a/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java +++ b/oak-api/src/main/java/org/apache/jackrabbit/oak/api/jmx/package-info.java @@ -15,7 +15,7 @@ * limitations under the License. */ -@Version("4.14.0") +@Version("4.15.0") package org.apache.jackrabbit.oak.api.jmx; import org.osgi.annotation.versioning.Version; diff --git a/oak-core/src/main/java/org/apache/jackrabbit/oak/management/RepositoryManager.java b/oak-core/src/main/java/org/apache/jackrabbit/oak/management/RepositoryManager.java index 738b2bde8f..c465d270f0 100644 --- a/oak-core/src/main/java/org/apache/jackrabbit/oak/management/RepositoryManager.java +++ b/oak-core/src/main/java/org/apache/jackrabbit/oak/management/RepositoryManager.java @@ -56,6 +56,7 @@ import org.jetbrains.annotations.NotNull; */ public class RepositoryManager extends AnnotatedStandardMBean implements RepositoryManagementMBean { private final Whiteboard whiteboard; + private String sessionSaveConfig; public RepositoryManager(@NotNull Whiteboard whiteboard) { super(RepositoryManagementMBean.class); @@ -277,4 +278,14 @@ public class RepositoryManager extends AnnotatedStandardMBean implements Reposit } })); } + + @Override + public String getSessionSaveDelayerConfig() { + return sessionSaveConfig; + } + + @Override + public void setSessionSaveDelayerConfig(String config) { + this.sessionSaveConfig = config; + } } diff --git a/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/delegate/SessionDelegate.java b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/delegate/SessionDelegate.java index 20b0b702b6..b4baf682fa 100644 --- a/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/delegate/SessionDelegate.java +++ b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/delegate/SessionDelegate.java @@ -54,6 +54,7 @@ import org.apache.jackrabbit.oak.jcr.observation.EventFactory; import org.apache.jackrabbit.oak.jcr.session.RefreshStrategy; import org.apache.jackrabbit.oak.jcr.session.RefreshStrategy.Composite; import org.apache.jackrabbit.oak.jcr.session.SessionNamespaces; +import org.apache.jackrabbit.oak.jcr.session.SessionSaveDelayer; import org.apache.jackrabbit.oak.jcr.session.SessionStats; import org.apache.jackrabbit.oak.jcr.session.SessionStats.Counters; import org.apache.jackrabbit.oak.jcr.session.operation.SessionOperation; @@ -138,6 +139,8 @@ public class SessionDelegate { private final SessionNamespaces namespaces; + private final SessionSaveDelayer sessionSaveDelayer; + /** * Create a new session delegate for a {@code ContentSession}. The refresh behaviour of the * session is governed by the value of the {@code refreshInterval} argument: if the session @@ -150,6 +153,7 @@ public class SessionDelegate { * @param securityProvider the security provider * @param refreshStrategy the refresh strategy used for auto refreshing this session * @param statisticManager the statistics manager for tracking session operations + * @param sessionSaveDelayer the session save delay mechanism */ public SessionDelegate( @NotNull ContentSession contentSession, @@ -157,7 +161,8 @@ public class SessionDelegate { @NotNull RefreshStrategy refreshStrategy, @NotNull ThreadLocal<Long> threadSaveCount, @NotNull StatisticManager statisticManager, - @NotNull Clock clock) { + @NotNull Clock clock, + @NotNull SessionSaveDelayer sessionSaveDelayer) { this.contentSession = requireNonNull(contentSession); this.securityProvider = requireNonNull(securityProvider); this.root = contentSession.getLatestRoot(); @@ -176,6 +181,7 @@ public class SessionDelegate { readDuration = statisticManager.getTimer(SESSION_READ_DURATION); writeCounter = statisticManager.getMeter(SESSION_WRITE_COUNTER); writeDuration = statisticManager.getTimer(SESSION_WRITE_DURATION); + this.sessionSaveDelayer = sessionSaveDelayer; } @NotNull @@ -392,6 +398,7 @@ public class SessionDelegate { if (userData != null) { info.put(EventFactory.USER_DATA, userData); } + sessionSaveDelayer.delayIfNeeded(); root.commit(Collections.unmodifiableMap(info)); if (permissionProvider != null && refreshPermissionProvider) { permissionProvider.refresh(); @@ -451,6 +458,7 @@ public class SessionDelegate { } catch (IOException e) { log.warn("Error while closing connection", e); } + sessionSaveDelayer.close(); } @NotNull diff --git a/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/repository/RepositoryImpl.java b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/repository/RepositoryImpl.java index b7f607b33b..d5b9edd4bd 100644 --- a/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/repository/RepositoryImpl.java +++ b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/repository/RepositoryImpl.java @@ -54,6 +54,7 @@ import org.apache.jackrabbit.oak.jcr.delegate.SessionDelegate; import org.apache.jackrabbit.oak.jcr.session.RefreshStrategy; import org.apache.jackrabbit.oak.jcr.session.RefreshStrategy.Composite; import org.apache.jackrabbit.oak.jcr.session.SessionContext; +import org.apache.jackrabbit.oak.jcr.session.SessionSaveDelayer; import org.apache.jackrabbit.oak.jcr.session.SessionStats; import org.apache.jackrabbit.oak.jcr.version.FrozenNodeLogger; import org.apache.jackrabbit.oak.plugins.observation.CommitRateLimiter; @@ -120,6 +121,7 @@ public class RepositoryImpl implements JackrabbitRepository { private final MountInfoProvider mountInfoProvider; private final BlobAccessProvider blobAccessProvider; private final SessionQuerySettingsProvider sessionQuerySettingsProvider; + private final SessionSaveDelayer sessionSaveDelayer; /** * {@link ThreadLocal} counter that keeps track of the save operations @@ -176,6 +178,7 @@ public class RepositoryImpl implements JackrabbitRepository { this.frozenNodeLogger = new FrozenNodeLogger(clock, whiteboard); this.sessionQuerySettingsProvider = Optional.ofNullable(WhiteboardUtils.getService(whiteboard, SessionQuerySettingsProvider.class)) .orElseGet(() -> new FastQuerySizeSettingsProvider(fastQueryResultSize)); + this.sessionSaveDelayer = new SessionSaveDelayer(whiteboard); } //---------------------------------------------------------< Repository >--- @@ -324,7 +327,7 @@ public class RepositoryImpl implements JackrabbitRepository { return new SessionDelegate( contentSession, securityProvider, refreshStrategy, - threadSaveCount, statisticManager, clock) { + threadSaveCount, statisticManager, clock, sessionSaveDelayer) { // Defer session MBean registration to avoid cluttering the // JMX name space with short lived sessions @@ -354,6 +357,7 @@ public class RepositoryImpl implements JackrabbitRepository { statisticManager.dispose(); gcMonitorRegistration.unregister(); frozenNodeLogger.close(); + sessionSaveDelayer.close(); clock.close(); new ExecutorCloser(scheduledExecutor).close(); if (contentRepository instanceof Closeable) { diff --git a/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayer.java b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayer.java new file mode 100644 index 0000000000..6848b54373 --- /dev/null +++ b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayer.java @@ -0,0 +1,163 @@ +/* + * 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.jcr.session; + +import static org.apache.jackrabbit.oak.spi.toggle.Feature.newFeature; + +import java.io.Closeable; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.apache.jackrabbit.guava.common.base.Strings; +import org.apache.jackrabbit.oak.api.jmx.RepositoryManagementMBean; +import org.apache.jackrabbit.oak.spi.toggle.Feature; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.WhiteboardUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A delay mechanism for Session.save() operations. By default, Session.save + * calls are not delayed. If enabled, some of the save() operations can be + * delayed for a certain number of microseconds. + * + * This facility is enabled / disabled via feature toggle, and controlled via + * JMX bean, or (for testing) via two system properties. There is no attempt to + * control the delay, or which threads to delay, from within. It is meant for + * emergency situation, specially for cases where some threads write too much. + */ +public class SessionSaveDelayer implements Closeable { + + private static final Logger LOG = LoggerFactory.getLogger(SessionSaveDelayer.class); + + private static final String FT_SAVE_DELAY_NAME = "FT_SAVE_DELAY_OAK-11766"; + private static final String ENABLED_PROP_NAME = "oak.sessionSaveDelayer"; + private static final String CONFIG_PROP_NAME = "oak.sessionSaveDelayerConfig"; + + private final boolean enabledViaSysPropertey = Boolean.getBoolean(ENABLED_PROP_NAME); + private final String sysPropertyConfig = System.getProperty(CONFIG_PROP_NAME, ""); + private final Feature feature; + private final Whiteboard whiteboard; + private final AtomicBoolean closed = new AtomicBoolean(); + + private RepositoryManagementMBean mbean; + private String lastConfigJson; + private SessionSaveDelayerConfig lastConfig; + + public SessionSaveDelayer(@NotNull Whiteboard whiteboard) { + this.feature = newFeature(FT_SAVE_DELAY_NAME, whiteboard); + LOG.info("Initialized"); + if (enabledViaSysPropertey) { + LOG.info("Enabled via system property " + ENABLED_PROP_NAME); + } + this.whiteboard = whiteboard; + } + + /** + * Gets the name of the current thread. + * + * @return the current thread name + */ + @NotNull + public static String getCurrentThreadName() { + return Thread.currentThread().getName(); + } + + /** + * Gets the stack trace of the current thread as a string. + * + * @return the current stack trace as a formatted string, or null if no stack trace is available + */ + @Nullable + public static String getCurrentStackTrace() { + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + if (stackTrace == null || stackTrace.length == 0) { + return null; + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < stackTrace.length; i++) { + if (i > 0) { + sb.append("\n\tat "); + } else { + sb.append("at "); + } + sb.append(stackTrace[i]); + } + return sb.toString(); + } + + private RepositoryManagementMBean getRepositoryMBean() { + if (mbean == null) { + mbean = WhiteboardUtils.getService(whiteboard, RepositoryManagementMBean.class); + } + return mbean; + } + + public long delayIfNeeded() { + if (closed.get() || (!feature.isEnabled() && !enabledViaSysPropertey)) { + return 0; + } + String config = sysPropertyConfig; + RepositoryManagementMBean mbean = getRepositoryMBean(); + if (mbean != null) { + String jmxConfig = mbean.getSessionSaveDelayerConfig(); + if (!Strings.isNullOrEmpty(jmxConfig)) { + config = jmxConfig; + } + } + if (Strings.isNullOrEmpty(config)) { + return 0; + } + if (!config.equals(lastConfigJson)) { + lastConfigJson = config; + try { + // reset, if already set + lastConfig = null; + lastConfig = SessionSaveDelayerConfig.fromJson(config); + } catch (IllegalArgumentException e) { + LOG.warn("Can not parse config " + config, e); + // don't delay + return 0; + } + } + if (lastConfig == null) { + return 0; + } + String threadName = Thread.currentThread().getName(); + long delayNanos = lastConfig.getDelayNanos(threadName, null); + if (delayNanos > 0) { + long millis = delayNanos / 1_000_000; + int nanos = (int) (delayNanos % 1_000_000); + try { + Thread.sleep(millis, nanos); + } catch (InterruptedException e) { + // ignore + } + } + return delayNanos; + } + + @Override + public void close() { + if (!closed.getAndSet(true)) { + feature.close(); + } + } + +} diff --git a/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfig.java b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfig.java new file mode 100644 index 0000000000..bd2ede324c --- /dev/null +++ b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfig.java @@ -0,0 +1,204 @@ +/* + * 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.jcr.session; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.apache.jackrabbit.guava.common.base.Strings; +import org.apache.jackrabbit.oak.commons.json.JsonObject; +import org.apache.jackrabbit.oak.commons.json.JsopBuilder; +import org.apache.jackrabbit.oak.commons.json.JsopTokenizer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Configuration parser for a session save delay JSON configuration: + * <pre> + * { + * "entries": [ + * { + * "delayMillis": 1.0, + * "threadNameRegex": "thread-.*", + * "stackTraceRegex": ".*SomeClass.*" + * }, + * { + * "delayMillis": 0.5, + * "threadNameRegex": "worker-\\d+" + * } + * ] + * } + * </pre> + */ +public class SessionSaveDelayerConfig { + + private static final Logger LOG = LoggerFactory.getLogger(SessionSaveDelayerConfig.class); + + private final List<DelayEntry> entries; + + public SessionSaveDelayerConfig(@NotNull List<DelayEntry> entries) { + this.entries = new ArrayList<>(entries); + } + + @NotNull + public static SessionSaveDelayerConfig fromJson(@NotNull String jsonConfig) throws IllegalArgumentException { + if (Strings.isNullOrEmpty(jsonConfig)) { + return new SessionSaveDelayerConfig(List.of()); + } + try { + JsopTokenizer tokenizer = new JsopTokenizer(jsonConfig); + tokenizer.read('{'); + JsonObject root = JsonObject.create(tokenizer); + List<DelayEntry> entries = new ArrayList<>(); + String entriesJson = root.getProperties().get("entries"); + if (entriesJson != null) { + JsopTokenizer entryTokenizer = new JsopTokenizer(entriesJson); + entryTokenizer.read('['); + if (!entryTokenizer.matches(']')) { + do { + if (entryTokenizer.matches('{')) { + DelayEntry entry = parseDelayEntry(JsonObject.create(entryTokenizer)); + if (entry != null) { + entries.add(entry); + } + } else { + throw new IllegalArgumentException("Expected object in entries array"); + } + } while (entryTokenizer.matches(',')); + entryTokenizer.read(']'); + } + } + return new SessionSaveDelayerConfig(entries); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse JSON configuration: " + e.getMessage(), e); + } + } + + public List<DelayEntry> getEntries() { + return entries; + } + + public long getDelayNanos(@NotNull String threadName, @Nullable String stackTrace) { + for (DelayEntry d : entries) { + if (d.matches(threadName, stackTrace)) { + return d.delayNanos; + } + } + return 0; + } + + @Nullable + private static DelayEntry parseDelayEntry(JsonObject entryObj) { + String delayMillis = entryObj.getProperties().get("delayMillis"); + String threadNameRegex = entryObj.getProperties().get("threadNameRegex"); + String stackTraceRegex = entryObj.getProperties().get("stackTraceRegex"); + if (delayMillis == null || threadNameRegex == null) { + LOG.warn("Skipping entry with missing required fields (delay or threadNameRegex)"); + return null; + } + try { + double delay = Double.parseDouble(delayMillis); + if (delay < 0) { + LOG.warn("Skipping entry with negative delay"); + return null; + } + Pattern threadPattern = Pattern.compile(JsopTokenizer.decodeQuoted(threadNameRegex)); + Pattern stackPattern = null; + if (stackTraceRegex != null) { + stackPattern = Pattern.compile(JsopTokenizer.decodeQuoted(stackTraceRegex)); + } + return new DelayEntry(delay, threadPattern, stackPattern); + } catch (NumberFormatException e) { + LOG.warn("Skipping entry with invalid delay value: {}", delayMillis); + return null; + } catch (PatternSyntaxException e) { + LOG.warn("Skipping entry with invalid regex pattern: {}", e.getMessage()); + return null; + } + } + + @Override + public String toString() { + JsopBuilder json = new JsopBuilder(); + json.object().key("entries").array(); + for (DelayEntry entry : entries) { + entry.toJson(json); + } + json.endArray().endObject(); + return JsopBuilder.prettyPrint(json.toString()); + } + + public static class DelayEntry { + private final long delayNanos; + private final Pattern threadNamePattern; + private final Pattern stackTracePattern; + + public DelayEntry(double delayMillis, @NotNull Pattern threadNamePattern, @Nullable Pattern stackTracePattern) { + this.delayNanos = (long) (delayMillis * 1_000_000); + this.threadNamePattern = threadNamePattern; + this.stackTracePattern = stackTracePattern; + } + + public long getDelayNanos() { + return delayNanos; + } + + @NotNull + public Pattern getThreadNamePattern() { + return threadNamePattern; + } + + @Nullable + public Pattern getStackTracePattern() { + return stackTracePattern; + } + + boolean matches(@NotNull String threadName, @Nullable String stackTrace) { + if (!threadNamePattern.matcher(threadName).matches()) { + return false; + } + if (stackTracePattern != null) { + if (stackTrace == null) { + stackTrace = SessionSaveDelayer.getCurrentStackTrace(); + } + return stackTracePattern.matcher(stackTrace).find(); + } + return true; + } + + @Override + public String toString() { + return toJson(new JsopBuilder()).toString(); + } + + public JsopBuilder toJson(JsopBuilder json) { + json.object(); + double delayMillis = delayNanos / 1_000_000.0; + json.key("delayMillis").encodedValue(Double.toString(delayMillis)); + json.key("threadNameRegex").value(threadNamePattern.pattern()); + if (stackTracePattern != null) { + json.key("stackTraceRegex").value(stackTracePattern.pattern()); + } + return json.endObject(); + } + + } +} \ No newline at end of file diff --git a/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/delegate/AbstractDelegatorTest.java b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/delegate/AbstractDelegatorTest.java index 68a35e9fc8..649d0a5686 100644 --- a/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/delegate/AbstractDelegatorTest.java +++ b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/delegate/AbstractDelegatorTest.java @@ -22,6 +22,7 @@ import org.apache.jackrabbit.oak.api.ContentSession; import org.apache.jackrabbit.oak.api.Root; import org.apache.jackrabbit.oak.api.Tree; import org.apache.jackrabbit.oak.jcr.session.RefreshStrategy; +import org.apache.jackrabbit.oak.jcr.session.SessionSaveDelayer; import org.apache.jackrabbit.oak.spi.security.SecurityProvider; import org.apache.jackrabbit.oak.spi.security.authorization.AuthorizationConfiguration; import org.apache.jackrabbit.oak.spi.security.authorization.permission.PermissionAware; @@ -63,7 +64,8 @@ public abstract class AbstractDelegatorTest { Whiteboard wb = new DefaultWhiteboard(); StatisticManager statisticManager = new StatisticManager(wb, executorService); return spy(new SessionDelegate(mockContentSession(root), mockSecurityProvider(root, pp), - RefreshStrategy.Composite.create(), new ThreadLocal<>(), statisticManager, new Clock.Virtual())); + RefreshStrategy.Composite.create(), new ThreadLocal<>(), statisticManager, new Clock.Virtual(), + new SessionSaveDelayer(wb))); } @NotNull diff --git a/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfigTest.java b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfigTest.java new file mode 100644 index 0000000000..71e1694b8e --- /dev/null +++ b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfigTest.java @@ -0,0 +1,257 @@ +/* + * 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.jcr.session; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.List; + +import org.junit.Test; + +/** + * Tests for {@link SessionSaveDelayerConfig}. + */ +public class SessionSaveDelayerConfigTest { + + @Test + public void testEmptyConfiguration() { + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(""); + assertNotNull(config); + assertTrue(config.getEntries().isEmpty()); + } + + @Test + public void testNullConfiguration() { + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(null); + assertNotNull(config); + assertTrue(config.getEntries().isEmpty()); + } + + @Test + public void testBasicConfiguration() { + String json = "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 0.5,\n" + + " \"threadNameRegex\": \"worker-\\\\d+\"\n" + + " },\n" + + " {\n" + + " \"delayMillis\": 1,\n" + + " \"threadNameRegex\": \"thread-.*\",\n" + + " \"stackTraceRegex\": \".*SomeClass.*\"\n" + + " }\n" + + " ]\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + assertNotNull(config); + + List<SessionSaveDelayerConfig.DelayEntry> entries = config.getEntries(); + assertEquals(2, entries.size()); + + SessionSaveDelayerConfig.DelayEntry first = entries.get(0); + assertEquals(500_000L, first.getDelayNanos()); + assertEquals("worker-\\d+", first.getThreadNamePattern().pattern()); + assertNull(first.getStackTracePattern()); + + SessionSaveDelayerConfig.DelayEntry second = entries.get(1); + assertEquals(1_000_000L, second.getDelayNanos()); + assertEquals("thread-.*", second.getThreadNamePattern().pattern()); + assertNotNull(second.getStackTracePattern()); + assertEquals(".*SomeClass.*", second.getStackTracePattern().pattern()); + } + + @Test + public void testEntryMatching() { + String json = "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 1,\n" + + " \"threadNameRegex\": \"thread-.*\",\n" + + " \"stackTraceRegex\": \".*SomeClass.*\"\n" + + " },\n" + + " {\n" + + " \"delayMillis\": 0.5,\n" + + " \"threadNameRegex\": \"worker-\\\\d+\"\n" + + " }\n" + + " ]\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + List<SessionSaveDelayerConfig.DelayEntry> entries = config.getEntries(); + + SessionSaveDelayerConfig.DelayEntry first = entries.get(0); + SessionSaveDelayerConfig.DelayEntry second = entries.get(1); + + assertTrue(first.matches("thread-123", "at com.example.SomeClass.method()")); + assertTrue(first.matches("thread-abc", "SomeClass is here")); + assertFalse(first.matches("thread-123", "no matching class")); + assertFalse(first.matches("thread-123", null)); + assertFalse(first.matches("worker-123", "at com.example.SomeClass.method()")); + + assertTrue(second.matches("worker-123", "any stack trace")); + assertTrue(second.matches("worker-456", null)); + assertFalse(second.matches("worker-abc", "any stack trace")); + assertFalse(second.matches("thread-123", "any stack trace")); + } + + @Test + public void testConfigurationWithMissingFields() { + String json = "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 1,\n" + + " \"threadNameRegex\": \"thread-.*\"\n" + + " },\n" + + " {\n" + + " \"threadNameRegex\": \"worker-\\\\d+\"\n" + + " },\n" + + " {\n" + + " \"delayMillis\": 0.5\n" + + " }\n" + + " ]\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + assertNotNull(config); + + List<SessionSaveDelayerConfig.DelayEntry> entries = config.getEntries(); + // Only the first entry should be valid (has both delay and threadNameRegex) + assertEquals(1, entries.size()); + + SessionSaveDelayerConfig.DelayEntry entry = entries.get(0); + assertEquals(1000_000L, entry.getDelayNanos()); + assertEquals("thread-.*", entry.getThreadNamePattern().pattern()); + assertNull(entry.getStackTracePattern()); + } + + @Test + public void testConfigurationWithInvalidValues() { + String json = "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": \"invalid\",\n" + + " \"threadNameRegex\": \"thread-.*\"\n" + + " },\n" + + " {\n" + + " \"delayMillis\": -100,\n" + + " \"threadNameRegex\": \"worker-\\\\d+\"\n" + + " },\n" + + " {\n" + + " \"delay\": 500,\n" + + " \"threadNameRegex\": \"[invalid-regex\"\n" + + " }\n" + + " ]\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + assertNotNull(config); + + // All entries should be invalid and skipped + assertTrue(config.getEntries().isEmpty()); + } + + @Test + public void testEmptyEntriesArray() { + String json = "{\n" + + " \"entries\": []\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + assertNotNull(config); + assertTrue(config.getEntries().isEmpty()); + } + + @Test + public void testConfigurationWithoutEntriesProperty() { + String json = "{\n" + + " \"someOtherProperty\": \"value\"\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + assertNotNull(config); + assertTrue(config.getEntries().isEmpty()); + } + + @Test + public void testInvalidJsonThrowsException() { + String invalidJson = "{ invalid json }"; + + try { + SessionSaveDelayerConfig.fromJson(invalidJson); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("Failed to parse JSON configuration")); + } + } + + @Test + public void testDelayConfigToString() { + String json = "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 1.0,\n" + + " \"threadNameRegex\": \"thread-.*\",\n" + + " \"stackTraceRegex\": \".*SomeClass.*\"\n" + + " }\n" + + " ]\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + + assertEquals("{\n" + + " \"entries\": [{\n" + + " \"delayMillis\": 1.0, \"threadNameRegex\": \"thread-.*\", \"stackTraceRegex\": \".*SomeClass.*\"\n" + + " }]\n" + + "}", config.toString()); + } + + @Test + public void testComplexRegexPatterns() { + String json = "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 2,\n" + + " \"threadNameRegex\": \"(?i)pool-\\\\d+-thread-\\\\d+\",\n" + + " \"stackTraceRegex\": \".*\\\\.(save|update|delete)\\\\(.*\\\\).*\"\n" + + " }\n" + + " ]\n" + + "}"; + + SessionSaveDelayerConfig config = SessionSaveDelayerConfig.fromJson(json); + assertNotNull(config); + + List<SessionSaveDelayerConfig.DelayEntry> entries = config.getEntries(); + assertEquals(1, entries.size()); + + SessionSaveDelayerConfig.DelayEntry entry = entries.get(0); + assertEquals(2_000_000L, entry.getDelayNanos()); + + // Test case-insensitive thread name matching + assertTrue(entry.matches("pool-1-thread-5", "at com.example.Service.save()")); + assertTrue(entry.matches("POOL-2-THREAD-10", "at com.example.Service.update()")); + + // Test stack trace pattern matching + assertTrue(entry.matches("pool-1-thread-1", "at com.example.Repository.delete(Repository.java:100)")); + assertFalse(entry.matches("pool-1-thread-1", "at com.example.Service.get()")); + } +} \ No newline at end of file diff --git a/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerTest.java b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerTest.java new file mode 100644 index 0000000000..b53297c939 --- /dev/null +++ b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerTest.java @@ -0,0 +1,238 @@ +/* + * 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.jcr.session; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.apache.jackrabbit.oak.api.jmx.RepositoryManagementMBean; +import org.apache.jackrabbit.oak.commons.junit.TemporarySystemProperty; +import org.apache.jackrabbit.oak.spi.whiteboard.DefaultWhiteboard; +import org.apache.jackrabbit.oak.spi.whiteboard.Whiteboard; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * Tests for {@link SessionSaveDelayer}. + */ +public class SessionSaveDelayerTest { + + private static final String ENABLED_PROP_NAME = "oak.sessionSaveDelayer"; + private static final String CONFIG_PROP_NAME = "oak.sessionSaveDelayerConfig"; + + @Rule + public TemporarySystemProperty temp; + + private Whiteboard whiteboard; + private SessionSaveDelayer delayer; + + @Before + public void setUp() { + whiteboard = new DefaultWhiteboard(); + delayer = new SessionSaveDelayer(whiteboard); + } + + @After + public void tearDown() { + if (delayer != null) { + delayer.close(); + } + } + + @Test + public void testGetCurrentThreadName() { + String threadName = SessionSaveDelayer.getCurrentThreadName(); + assertNotNull(threadName); + assertEquals(Thread.currentThread().getName(), threadName); + } + + @Test + public void testGetCurrentStackTrace() { + String stackTrace = SessionSaveDelayer.getCurrentStackTrace(); + assertNotNull(stackTrace); + assertTrue(stackTrace.contains("testGetCurrentStackTrace")); + assertTrue(stackTrace.contains("at ")); + } + + @Test + public void testDelayIfNeededDisabled() { + System.clearProperty(ENABLED_PROP_NAME); + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(0, delay); + } + + @Test + public void testDelayIfNeededEnabledViaSystemProperty() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.clearProperty(CONFIG_PROP_NAME); + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(0, delay); + } + + @Test + public void testDelayIfNeededWithSystemPropertyConfig() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.setProperty(CONFIG_PROP_NAME, "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 0.1,\n" + + " \"threadNameRegex\": \".*\"\n" + + " }\n" + + " ]\n" + + "}"); + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(100_000L, delay); + } + + @Test + public void testDelayIfNeededWithJMXConfig() { + System.setProperty(ENABLED_PROP_NAME, "true"); + RepositoryManagementMBean mbean = mock(RepositoryManagementMBean.class); + when(mbean.getSessionSaveDelayerConfig()).thenReturn("{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 0.2,\n" + + " \"threadNameRegex\": \".*\"\n" + + " }\n" + + " ]\n" + + "}"); + whiteboard.register(RepositoryManagementMBean.class, mbean, Map.of()); + delayer = new SessionSaveDelayer(whiteboard); + long startTime = System.nanoTime(); + long delay = delayer.delayIfNeeded(); + long actualDelay = System.nanoTime() - startTime; + assertEquals(200_000L, delay); + assertTrue("Actual delay should be at least close to expected", + actualDelay >= 100_000L); + } + + @Test + public void testDelayIfNeededWithNonMatchingThreadName() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.setProperty(CONFIG_PROP_NAME, "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 5.0,\n" + + " \"threadNameRegex\": \"non-matching-pattern\"\n" + + " }\n" + + " ]\n" + + "}"); + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(0, delay); + } + + @Test + public void testDelayIfNeededWithInvalidConfig() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.setProperty(CONFIG_PROP_NAME, "{ invalid json }"); + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(0, delay); + } + + @Test + public void testDelayIfNeededConfigCaching() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.setProperty(CONFIG_PROP_NAME, "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 0.1,\n" + + " \"threadNameRegex\": \".*\"\n" + + " }\n" + + " ]\n" + + "}"); + delayer = new SessionSaveDelayer(whiteboard); + long delay1 = delayer.delayIfNeeded(); + assertEquals(100_000L, delay1); + long delay2 = delayer.delayIfNeeded(); + assertEquals(100_000L, delay2); + } + + @Test + public void testDelayIfNeededEmptyConfig() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.setProperty(CONFIG_PROP_NAME, ""); + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(0, delay); + } + + @Test + public void testDelayIfNeededAfterClose() { + long delay = delayer.delayIfNeeded(); + assertEquals(0, delay); + } + + @Test + public void testJMXConfigOverridesSystemProperty() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.setProperty(CONFIG_PROP_NAME, "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 0.1,\n" + + " \"threadNameRegex\": \".*\"\n" + + " }\n" + + " ]\n" + + "}"); + RepositoryManagementMBean mbean = mock(RepositoryManagementMBean.class); + when(mbean.getSessionSaveDelayerConfig()).thenReturn("{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 0.3,\n" + + " \"threadNameRegex\": \".*\"\n" + + " }\n" + + " ]\n" + + "}"); + + whiteboard.register(RepositoryManagementMBean.class, mbean, Map.of()); + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(300_000L, delay); + } + + @Test + public void testDelayIfNeededWithMultipleEntries() { + System.setProperty(ENABLED_PROP_NAME, "true"); + System.setProperty(CONFIG_PROP_NAME, "{\n" + + " \"entries\": [\n" + + " {\n" + + " \"delayMillis\": 0.1,\n" + + " \"threadNameRegex\": \"non-matching\"\n" + + " },\n" + + " {\n" + + " \"delayMillis\": 0.2,\n" + + " \"threadNameRegex\": \".*\"\n" + + " }\n" + + " ]\n" + + "}"); + + delayer = new SessionSaveDelayer(whiteboard); + long delay = delayer.delayIfNeeded(); + assertEquals(200_000L, delay); + } +} \ No newline at end of file
