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

Reply via email to