This is an automated email from the ASF dual-hosted git repository.
thomasm pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/jackrabbit-oak.git
The following commit(s) were added to refs/heads/trunk by this push:
new 201c1c894f OAK-11766 Write Throttling Mechanism - Session.save() delay
(#2339)
201c1c894f is described below
commit 201c1c894f233b16ed0559011fee32d21b0ce257
Author: Thomas Mueller <[email protected]>
AuthorDate: Tue Jun 24 13:32:49 2025 +0200
OAK-11766 Write Throttling Mechanism - Session.save() delay (#2339)
* OAK-11766 Write Throttling Mechanism - Session.save() delay
* OAK-11766 Write Throttling Mechanism - Session.save() delay
* OAK-11766 Write Throttling Mechanism - Session.save() delay
* 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 | 9 +-
.../oak/jcr/repository/RepositoryImpl.java | 6 +-
.../oak/jcr/session/SessionSaveDelayer.java | 136 ++++
.../oak/jcr/session/SessionSaveDelayerConfig.java | 288 +++++++++
.../oak/jcr/delegate/AbstractDelegatorTest.java | 4 +-
.../jcr/session/SessionSaveDelayerConfigTest.java | 691 +++++++++++++++++++++
.../oak/jcr/session/SessionSaveDelayerTest.java | 401 ++++++++++++
10 files changed, 1560 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..34c3c70fb6 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(userData);
root.commit(Collections.unmodifiableMap(info));
if (permissionProvider != null && refreshPermissionProvider) {
permissionProvider.refresh();
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..2a9d1f1695
--- /dev/null
+++
b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayer.java
@@ -0,0 +1,136 @@
+/*
+ * 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.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 cachedMbean;
+ private String lastConfigJson;
+ private SessionSaveDelayerConfig lastConfig;
+ private volatile boolean logNextDelay;
+
+ 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;
+ }
+
+ private RepositoryManagementMBean getRepositoryMBean() {
+ if (cachedMbean == null) {
+ cachedMbean = WhiteboardUtils.getService(whiteboard,
RepositoryManagementMBean.class);
+ }
+ return cachedMbean;
+ }
+
+ public long delayIfNeeded(String userData) {
+ 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)) {
+ logNextDelay = true;
+ lastConfigJson = config;
+ try {
+ // reset, if already set
+ lastConfig = null;
+ lastConfig = SessionSaveDelayerConfig.fromJson(config);
+ LOG.info("New config: {}", lastConfig.toString());
+ } catch (IllegalArgumentException e) {
+ LOG.warn("Can not parse config {}", e);
+ // don't delay
+ return 0;
+ }
+ }
+ if (lastConfig == null) {
+ return 0;
+ }
+ String threadName = Thread.currentThread().getName();
+ long delayNanos = lastConfig.getDelayNanos(threadName, userData, null);
+ if (delayNanos > 0) {
+ long millis = delayNanos / 1_000_000;
+ int nanos = (int) (delayNanos % 1_000_000);
+ if (logNextDelay) {
+ LOG.info("Sleep {} ms {} ns for user {}", millis, nanos,
userData);
+ logNextDelay = false;
+ }
+ try {
+ Thread.sleep(millis, nanos);
+ } catch (InterruptedException e) {
+ // ignore
+ Thread.currentThread().interrupt();
+ }
+ }
+ 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..e89614c103
--- /dev/null
+++
b/oak-jcr/src/main/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfig.java
@@ -0,0 +1,288 @@
+/*
+ * 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.concurrent.atomic.AtomicLong;
+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
userData, @Nullable String stackTrace) {
+ for (DelayEntry d : entries) {
+ if (d.matches(threadName, userData, stackTrace)) {
+ return d.getDelayNanos();
+ }
+ }
+ return 0;
+ }
+
+ @Nullable
+ private static DelayEntry parseDelayEntry(JsonObject entryObj) {
+ String delayMillis = entryObj.getProperties().get("delayMillis");
+ String threadNameRegex =
entryObj.getProperties().get("threadNameRegex");
+ String userDataRegex = entryObj.getProperties().get("userDataRegex");
+ String stackTraceRegex =
entryObj.getProperties().get("stackTraceRegex");
+ String maxSavesPerSecond =
entryObj.getProperties().get("maxSavesPerSecond");
+ 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;
+ }
+ double maxSaves = 0.0;
+ if (maxSavesPerSecond != null) {
+ maxSaves = Double.parseDouble(maxSavesPerSecond);
+ if (maxSaves < 0) {
+ LOG.warn("Skipping entry with negative maxSavesPerSecond");
+ return null;
+ }
+ }
+ Pattern threadPattern =
Pattern.compile(JsopTokenizer.decodeQuoted(threadNameRegex));
+ Pattern stackPattern = null;
+ if (stackTraceRegex != null) {
+ stackPattern =
Pattern.compile(JsopTokenizer.decodeQuoted(stackTraceRegex));
+ }
+ Pattern userDataPattern = null;
+ if (userDataRegex != null) {
+ userDataPattern =
Pattern.compile(JsopTokenizer.decodeQuoted(userDataRegex));
+ }
+ return new DelayEntry(delay, threadPattern, userDataPattern,
stackPattern, maxSaves);
+ } catch (NumberFormatException e) {
+ LOG.warn("Skipping entry with invalid delay value or
maxSavesPerSecond: {}", e.getMessage());
+ 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());
+ }
+
+ /**
+ * 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();
+ }
+
+ public static class DelayEntry {
+ private final long baseDelayNanos;
+ private final Pattern threadNamePattern;
+ private final Pattern stackTracePattern;
+ private final Pattern userDataPattern;
+ private final double maxSavesPerSecond;
+ private final AtomicLong lastMatch = new AtomicLong(0);
+
+ public DelayEntry(double delayMillis, @NotNull Pattern
threadNamePattern, @Nullable Pattern userDataPattern, @Nullable Pattern
stackTracePattern, double maxSavesPerSecond) {
+ this.baseDelayNanos = (long) (delayMillis * 1_000_000);
+ this.threadNamePattern = threadNamePattern;
+ this.userDataPattern = userDataPattern;
+ this.stackTracePattern = stackTracePattern;
+ this.maxSavesPerSecond = maxSavesPerSecond;
+ }
+
+ public long getDelayNanos() {
+ long totalDelayNanos = baseDelayNanos;
+ if (maxSavesPerSecond > 0) {
+ long currentTime = System.currentTimeMillis();
+ double intervalMs = 1000.0 / maxSavesPerSecond;
+ long lastMatchTime = lastMatch.get();
+ if (lastMatchTime > 0) {
+ long nextAllowedTime = lastMatchTime + (long) intervalMs;
+ if (currentTime < nextAllowedTime) {
+ long rateLimitDelayMs = nextAllowedTime - currentTime;
+ totalDelayNanos += rateLimitDelayMs * 1_000_000;
+ }
+ }
+ lastMatch.set(currentTime);
+ }
+ return totalDelayNanos;
+ }
+
+ public long getBaseDelayNanos() {
+ return baseDelayNanos;
+ }
+
+ public double getMaxSavesPerSecond() {
+ return maxSavesPerSecond;
+ }
+
+ @NotNull
+ public Pattern getThreadNamePattern() {
+ return threadNamePattern;
+ }
+
+ @Nullable
+ public Pattern getStackTracePattern() {
+ return stackTracePattern;
+ }
+
+ @Nullable
+ public Pattern getUserDataPattern() {
+ return userDataPattern;
+ }
+
+ boolean matches(@NotNull String threadName, @Nullable String userData,
@Nullable String stackTrace) {
+ if (!threadNamePattern.matcher(threadName).matches()) {
+ return false;
+ }
+ if (userDataPattern != null) {
+ if (userData == null) {
+ return false;
+ }
+ if (!userDataPattern.matcher(userData).find()) {
+ return false;
+ }
+ }
+ if (stackTracePattern != null) {
+ if (stackTrace == null) {
+ stackTrace =
SessionSaveDelayerConfig.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 = baseDelayNanos / 1_000_000.0;
+ json.key("delayMillis").encodedValue(Double.toString(delayMillis));
+ json.key("threadNameRegex").value(threadNamePattern.pattern());
+ if (userDataPattern != null) {
+ json.key("userDataRegex").value(userDataPattern.pattern());
+ }
+ if (stackTracePattern != null) {
+ json.key("stackTraceRegex").value(stackTracePattern.pattern());
+ }
+ if (maxSavesPerSecond > 0) {
+
json.key("maxSavesPerSecond").encodedValue(Double.toString(maxSavesPerSecond));
+ }
+ return json.endObject();
+ }
+
+ }
+}
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..2e0120ff6c
--- /dev/null
+++
b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerConfigTest.java
@@ -0,0 +1,691 @@
+/*
+ * 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", null, "at
com.example.SomeClass.method()"));
+ assertTrue(first.matches("thread-abc", null, "SomeClass is here"));
+ assertFalse(first.matches("thread-123", null, "no matching class"));
+ assertFalse(first.matches("thread-123", null, null));
+ assertFalse(first.matches("worker-123", null, "at
com.example.SomeClass.method()"));
+
+ assertTrue(second.matches("worker-123", null, "any stack trace"));
+ assertTrue(second.matches("worker-456", null, null));
+ assertFalse(second.matches("worker-abc", null, "any stack trace"));
+ assertFalse(second.matches("thread-123", null, "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", null, "at
com.example.Service.save()"));
+ assertTrue(entry.matches("POOL-2-THREAD-10", null, "at
com.example.Service.update()"));
+
+ // Test stack trace pattern matching
+ assertTrue(entry.matches("pool-1-thread-1", null, "at
com.example.Repository.delete(Repository.java:100)"));
+ assertFalse(entry.matches("pool-1-thread-1", null, "at
com.example.Service.get()"));
+ }
+
+ @Test
+ public void testUserDataPatternBasic() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 1.0,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"admin.*\"\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(1_000_000L, entry.getDelayNanos());
+
+ // Test userDataPattern matching
+ assertTrue(entry.matches("any-thread", "admin", null));
+ assertTrue(entry.matches("any-thread", "admin123", null));
+ assertTrue(entry.matches("any-thread", "adminUser", null));
+ assertFalse(entry.matches("any-thread", "user", null));
+ assertFalse(entry.matches("any-thread", "testAdmin", null));
+ assertFalse(entry.matches("any-thread", null, null)); // null userData
should not match
+ }
+
+ @Test
+ public void testUserDataPatternWithComplexRegex() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.5,\n" +
+ " \"threadNameRegex\": \"worker-.*\",\n" +
+ " \"userDataRegex\":
\"(admin|root|system)@.*\\\\.com$\"\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);
+
+ // Test complex userDataPattern matching
+ assertTrue(entry.matches("worker-1", "[email protected]", null));
+ assertTrue(entry.matches("worker-2", "[email protected]", null));
+ assertTrue(entry.matches("worker-3", "[email protected]", null));
+ assertFalse(entry.matches("worker-1", "[email protected]", null));
+ assertFalse(entry.matches("worker-1", "[email protected]", null));
+ assertFalse(entry.matches("worker-1", "admin", null));
+ assertFalse(entry.matches("other-thread", "[email protected]", null));
// thread name doesn't match
+ }
+
+ @Test
+ public void testUserDataPatternCombinedWithStackTrace() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 2.0,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"privileged.*\",\n" +
+ " \"stackTraceRegex\": \".*Session\\\\.save.*\"\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);
+
+ // All conditions must match
+ assertTrue(entry.matches("any-thread", "privilegedUser", "at
javax.jcr.Session.save(Session.java:123)"));
+ assertFalse(entry.matches("any-thread", "privilegedUser", "at
javax.jcr.Session.refresh(Session.java:456)")); // stack trace doesn't match
+ assertFalse(entry.matches("any-thread", "normalUser", "at
javax.jcr.Session.save(Session.java:123)")); // userData doesn't match
+ assertFalse(entry.matches("any-thread", null, "at
javax.jcr.Session.save(Session.java:123)")); // null userData
+ }
+
+ @Test
+ public void testUserDataPatternMultipleEntries() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"admin.*\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"delayMillis\": 0.2,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"guest.*\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"delayMillis\": 0.3,\n" +
+ " \"threadNameRegex\": \".*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+
+ // Test that first matching entry is used
+ assertEquals(100_000L, config.getDelayNanos("thread-1", "admin",
null));
+ assertEquals(200_000L, config.getDelayNanos("thread-1", "guest123",
null));
+ assertEquals(300_000L, config.getDelayNanos("thread-1", "normalUser",
null)); // matches third entry (no userData pattern)
+ assertEquals(300_000L, config.getDelayNanos("thread-1", null, null));
// matches third entry (no userData pattern)
+ }
+
+ @Test
+ public void testUserDataPatternWithInvalidRegex() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 1.0,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"[invalid-regex\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+ assertNotNull(config);
+
+ // Entry should be skipped due to invalid regex
+ assertTrue(config.getEntries().isEmpty());
+ }
+
+ @Test
+ public void testUserDataPatternEmptyString() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 1.0,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"\"\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);
+
+ // Empty pattern should match empty string but not non-empty strings
+ assertTrue(entry.matches("any-thread", "", null));
+ assertTrue(entry.matches("any-thread", "anything", null)); // empty
regex matches anything using find()
+ assertFalse(entry.matches("any-thread", null, null)); // null userData
should not match
+ }
+
+ @Test
+ public void testUserDataPatternCaseInsensitive() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 1.0,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"(?i)ADMIN.*\"\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);
+
+ // Test case-insensitive matching
+ assertTrue(entry.matches("any-thread", "admin", null));
+ assertTrue(entry.matches("any-thread", "ADMIN", null));
+ assertTrue(entry.matches("any-thread", "Admin123", null));
+ assertTrue(entry.matches("any-thread", "administrator", null));
+ assertFalse(entry.matches("any-thread", "user", null));
+ }
+
+ @Test
+ public void testGetDelayNanosWithUserDataPattern() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 1.0,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"admin.*\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"delayMillis\": 0.5,\n" +
+ " \"threadNameRegex\": \".*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+
+ // When userData matches first entry's pattern
+ assertEquals(1_000_000L, config.getDelayNanos("thread-1", "admin",
null));
+ assertEquals(1_000_000L, config.getDelayNanos("thread-1", "admin123",
null));
+
+ // When userData doesn't match first entry but matches second (no
userData pattern)
+ assertEquals(500_000L, config.getDelayNanos("thread-1", "user", null));
+ assertEquals(500_000L, config.getDelayNanos("thread-1", "guest",
null));
+
+ // When userData is null, first entry shouldn't match but second should
+ assertEquals(500_000L, config.getDelayNanos("thread-1", null, null));
+ }
+
+ @Test
+ public void testRateLimitingBasic() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"maxSavesPerSecond\": 2.0\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(100_000L, entry.getBaseDelayNanos());
+ assertEquals(2.0, entry.getMaxSavesPerSecond(), 0.001);
+
+ // First call should only have base delay
+ long firstDelay = entry.getDelayNanos();
+ assertEquals(100_000L, firstDelay);
+
+ // Second call immediately should have additional rate limit delay
+ // With 2 saves per second, minimum interval is 500ms
+ long secondDelay = entry.getDelayNanos();
+ assertTrue("Second delay should be >= base delay + rate limit delay",
+ secondDelay >= 100_000L + 400_000_000L); // ~500ms in nanos
+ }
+
+ @Test
+ public void testRateLimitingWithZeroMaxSaves() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"maxSavesPerSecond\": 0.0\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(0.0, entry.getMaxSavesPerSecond(), 0.001);
+
+ // All calls should only have base delay since rate limiting is
disabled
+ assertEquals(100_000L, entry.getDelayNanos());
+ assertEquals(100_000L, entry.getDelayNanos());
+ assertEquals(100_000L, entry.getDelayNanos());
+ }
+
+ @Test
+ public void testRateLimitingWithNegativeMaxSaves() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"maxSavesPerSecond\": -1.0\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+ assertNotNull(config);
+
+ // Entry should be skipped due to negative maxSavesPerSecond
+ assertTrue(config.getEntries().isEmpty());
+ }
+
+ @Test
+ public void testRateLimitingWithInvalidMaxSaves() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"maxSavesPerSecond\": \"invalid\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+ assertNotNull(config);
+
+ // Entry should be skipped due to invalid maxSavesPerSecond
+ assertTrue(config.getEntries().isEmpty());
+ }
+
+ @Test
+ public void testRateLimitingHighFrequency() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.0,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"maxSavesPerSecond\": 10.0\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+ SessionSaveDelayerConfig.DelayEntry entry = config.getEntries().get(0);
+
+ // With 10 saves per second, minimum interval is 100ms
+ long firstDelay = entry.getDelayNanos();
+ assertEquals(0L, firstDelay); // No base delay
+
+ long secondDelay = entry.getDelayNanos();
+ assertTrue("Should have rate limit delay", secondDelay >=
90_000_000L); // ~100ms in nanos
+ }
+
+
+
+ @Test
+ public void testRateLimitingCombinedWithUserDataPattern() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"admin.*\",\n" +
+ " \"maxSavesPerSecond\": 1.0\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+ SessionSaveDelayerConfig.DelayEntry entry = config.getEntries().get(0);
+
+ // Test rate limiting only applies when entry matches
+ assertTrue(entry.matches("thread-1", "admin", null));
+ assertFalse(entry.matches("thread-1", "user", null));
+
+ // Rate limiting should work for matching entries
+ long firstDelay = entry.getDelayNanos();
+ assertEquals(100_000L, firstDelay);
+
+ long secondDelay = entry.getDelayNanos();
+ assertTrue("Should have rate limit delay", secondDelay >=
1_000_000_000L); // ~1000ms
+ }
+
+ @Test
+ public void testRateLimitingJsonSerialization() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.5,\n" +
+ " \"threadNameRegex\": \"worker-.*\",\n" +
+ " \"maxSavesPerSecond\": 3.5\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+ String serialized = config.toString();
+
+ // Check that serialization includes maxSavesPerSecond
+ assertTrue(serialized.contains("\"maxSavesPerSecond\": 3.5"));
+ assertTrue(serialized.contains("\"delayMillis\": 0.5"));
+ assertTrue(serialized.contains("\"threadNameRegex\": \"worker-.*\""));
+ }
+
+ @Test
+ public void testRateLimitingJsonSerializationWithoutMaxSaves() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.5,\n" +
+ " \"threadNameRegex\": \"worker-.*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+ String serialized = config.toString();
+
+ // Check that serialization doesn't include maxSavesPerSecond when
it's 0
+ assertFalse(serialized.contains("maxSavesPerSecond"));
+ }
+
+ @Test
+ public void testGetDelayNanosWithRateLimit() {
+ String json = "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"maxSavesPerSecond\": 2.0\n" +
+ " }\n" +
+ " ]\n" +
+ "}";
+
+ SessionSaveDelayerConfig config =
SessionSaveDelayerConfig.fromJson(json);
+
+ // First call should only have base delay
+ long firstDelay = config.getDelayNanos("thread-1", null, null);
+ assertEquals(100_000L, firstDelay);
+
+ // Second call should have additional rate limit delay
+ long secondDelay = config.getDelayNanos("thread-1", null, null);
+ assertTrue("Should have rate limit delay", secondDelay >=
400_000_000L);
+ }
+}
\ 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..f21001f07e
--- /dev/null
+++
b/oak-jcr/src/test/java/org/apache/jackrabbit/oak/jcr/session/SessionSaveDelayerTest.java
@@ -0,0 +1,401 @@
+/*
+ * 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 testGetCurrentStackTrace() {
+ String stackTrace = SessionSaveDelayerConfig.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(null);
+ 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(null);
+ 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(null);
+ 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.02,\n" +
+ " \"threadNameRegex\": \".*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}");
+ whiteboard.register(RepositoryManagementMBean.class, mbean, Map.of());
+ delayer = new SessionSaveDelayer(whiteboard);
+ long delay = delayer.delayIfNeeded(null);
+ assertEquals(20_000L, delay);
+ }
+
+ @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(null);
+ 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(null);
+ 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(null);
+ assertEquals(100_000L, delay1);
+ long delay2 = delayer.delayIfNeeded(null);
+ 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(null);
+ assertEquals(0, delay);
+ }
+
+ @Test
+ public void testDelayIfNeededAfterClose() {
+ long delay = delayer.delayIfNeeded(null);
+ 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(null);
+ 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(null);
+ assertEquals(200_000L, delay);
+ }
+
+ @Test
+ public void testDelayIfNeededWithUserDataPattern() {
+ System.setProperty(ENABLED_PROP_NAME, "true");
+ System.setProperty(CONFIG_PROP_NAME, "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"admin.*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}");
+
+ delayer = new SessionSaveDelayer(whiteboard);
+
+ // Test with matching userData
+ long delay = delayer.delayIfNeeded("admin");
+ assertEquals(100_000L, delay);
+
+ delay = delayer.delayIfNeeded("admin123");
+ assertEquals(100_000L, delay);
+
+ // Test with non-matching userData
+ delay = delayer.delayIfNeeded("user");
+ assertEquals(0L, delay);
+
+ // Test with null userData
+ delay = delayer.delayIfNeeded(null);
+ assertEquals(0L, delay);
+ }
+
+ @Test
+ public void testDelayIfNeededWithUserDataPatternComplexRegex() {
+ System.setProperty(ENABLED_PROP_NAME, "true");
+ System.setProperty(CONFIG_PROP_NAME, "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.2,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\":
\"(admin|root|system)@.*\\\\.com$\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}");
+
+ delayer = new SessionSaveDelayer(whiteboard);
+
+ // Test with matching email patterns
+ long delay = delayer.delayIfNeeded("[email protected]");
+ assertEquals(200_000L, delay);
+
+ delay = delayer.delayIfNeeded("[email protected]");
+ assertEquals(200_000L, delay);
+
+ delay = delayer.delayIfNeeded("[email protected]");
+ assertEquals(200_000L, delay);
+
+ // Test with non-matching patterns
+ delay = delayer.delayIfNeeded("[email protected]");
+ assertEquals(0L, delay);
+
+ delay = delayer.delayIfNeeded("[email protected]");
+ assertEquals(0L, delay);
+
+ delay = delayer.delayIfNeeded("admin");
+ assertEquals(0L, delay);
+ }
+
+ @Test
+ public void testDelayIfNeededWithUserDataPatternMultipleEntries() {
+ System.setProperty(ENABLED_PROP_NAME, "true");
+ System.setProperty(CONFIG_PROP_NAME, "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"admin.*\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"delayMillis\": 0.2,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"guest.*\"\n" +
+ " },\n" +
+ " {\n" +
+ " \"delayMillis\": 0.3,\n" +
+ " \"threadNameRegex\": \".*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}");
+
+ delayer = new SessionSaveDelayer(whiteboard);
+
+ // Test first entry matches
+ long delay = delayer.delayIfNeeded("admin");
+ assertEquals(100_000L, delay);
+
+ delay = delayer.delayIfNeeded("admin123");
+ assertEquals(100_000L, delay);
+
+ // Test second entry matches
+ delay = delayer.delayIfNeeded("guest123");
+ assertEquals(200_000L, delay);
+
+ // Test third entry matches (no userData pattern)
+ delay = delayer.delayIfNeeded("normalUser");
+ assertEquals(300_000L, delay);
+
+ // Test with null userData - should match third entry
+ delay = delayer.delayIfNeeded(null);
+ assertEquals(300_000L, delay);
+ }
+
+ @Test
+ public void testDelayIfNeededWithUserDataPatternAndJMX() {
+ System.setProperty(ENABLED_PROP_NAME, "true");
+
+ RepositoryManagementMBean mbean =
mock(RepositoryManagementMBean.class);
+ when(mbean.getSessionSaveDelayerConfig()).thenReturn("{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.15,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"privileged.*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}");
+
+ whiteboard.register(RepositoryManagementMBean.class, mbean, Map.of());
+ delayer = new SessionSaveDelayer(whiteboard);
+
+ // Test with matching userData
+ long delay = delayer.delayIfNeeded("privilegedUser");
+ assertEquals(150_000L, delay);
+
+ // Test with non-matching userData
+ delay = delayer.delayIfNeeded("normalUser");
+ assertEquals(0L, delay);
+ }
+
+ @Test
+ public void testDelayIfNeededWithUserDataPatternCaseInsensitive() {
+ System.setProperty(ENABLED_PROP_NAME, "true");
+ System.setProperty(CONFIG_PROP_NAME, "{\n" +
+ " \"entries\": [\n" +
+ " {\n" +
+ " \"delayMillis\": 0.1,\n" +
+ " \"threadNameRegex\": \".*\",\n" +
+ " \"userDataRegex\": \"(?i)ADMIN.*\"\n" +
+ " }\n" +
+ " ]\n" +
+ "}");
+
+ delayer = new SessionSaveDelayer(whiteboard);
+
+ // Test case-insensitive matching
+ long delay = delayer.delayIfNeeded("admin");
+ assertEquals(100_000L, delay);
+
+ delay = delayer.delayIfNeeded("ADMIN");
+ assertEquals(100_000L, delay);
+
+ delay = delayer.delayIfNeeded("Admin123");
+ assertEquals(100_000L, delay);
+
+ delay = delayer.delayIfNeeded("administrator");
+ assertEquals(100_000L, delay);
+
+ // Test non-matching
+ delay = delayer.delayIfNeeded("user");
+ assertEquals(0L, delay);
+ }
+
+
+
+}
\ No newline at end of file