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


Reply via email to