This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new e1f9ea87be perf(ai): parse the rule words only on a dictionary cache 
miss (#7829) (#7850)
e1f9ea87be is described below

commit e1f9ea87beb2fa9a4f253735a70549813e3743e1
Author: konlue <[email protected]>
AuthorDate: Mon Oct 19 13:55:26 2026 +0800

    perf(ai): parse the rule words only on a dictionary cache miss (#7829) 
(#7850)
    
    * fix(ai): build the sensitive-word dictionary off the redis io threads 
(#7830)
    
    * perf(ai): parse the rule words only on a dictionary cache miss (#7829)
---
 .../ai/sensitive/word/SensitiveWordPlugin.java     | 49 ++++++++++---
 .../ai/sensitive/word/SensitiveWordPluginTest.java | 82 ++++++++++++++++++++++
 2 files changed, 122 insertions(+), 9 deletions(-)

diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPlugin.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPlugin.java
index 8e17a7a0a9..2536119cf2 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPlugin.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/main/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPlugin.java
@@ -195,18 +195,15 @@ public class SensitiveWordPlugin extends 
AbstractShenyuPlugin {
         String redisKey = Objects.isNull(handle.getRedisKey())
                 ? SensitiveWordHandle.DEFAULT_REDIS_KEY : handle.getRedisKey();
         String dictionaryKey = handle.dictionaryKey();
-        List<String> words = parseWords(handle.getWords());
         CachedDictionary cached = 
SensitiveWordPluginDataHandler.DICTIONARIES.get().obtainHandle(dictionaryKey);
         Mono<AhoCorasick> automaton;
         if (Objects.nonNull(cached) && 
!cached.isExpired(handle.getRefreshIntervalSeconds())) {
             automaton = Mono.just(cached.getAutomaton());
         } else {
-            automaton = redisTemplate.opsForSet()
-                    .members(redisKey)
-                    .collectList()
-                    // building the automaton is cpu bound, keep it away from 
the event loop
-                    .map(loaded -> AhoCorasick.of(merge(loaded, words)))
-                    .subscribeOn(Schedulers.boundedElastic())
+            // the rule words are only needed on a cache miss: parsing them on 
every
+            // cache hit would repeat an O(n^2) parse for nothing (#7829)
+            List<String> words = parseWords(handle.getWords());
+            automaton = redisAutomaton(redisTemplate, redisKey, words)
                     .doOnNext(loaded -> 
SensitiveWordPluginDataHandler.DICTIONARIES.get()
                             .cachedHandle(dictionaryKey, new 
CachedDictionary(loaded)))
                     .onErrorResume(error -> {
@@ -216,7 +213,7 @@ public class SensitiveWordPlugin extends 
AbstractShenyuPlugin {
                         }
                         if (!words.isEmpty()) {
                             // the words of the rule do not depend on redis, 
they are still enforced
-                            return Mono.just(AhoCorasick.of(words));
+                            return ruleWordsAutomaton(words);
                         }
                         if (handle.isFailClosed()) {
                             LOG.warn("sensitive word plugin: no dictionary is 
available and failClosed is set,"
@@ -233,13 +230,47 @@ public class SensitiveWordPlugin extends 
AbstractShenyuPlugin {
         return automaton.publishOn(Schedulers.boundedElastic());
     }
 
+    /**
+     * The dictionary built from the redis set: the redis driver emits on its 
own io threads and
+     * building the automaton is cpu bound, so the construction hops to a 
bounded elastic worker.
+     *
+     * @param redisTemplate the redis template
+     * @param redisKey the redis key holding the dictionary words
+     * @param ruleWords the words of the rule
+     * @return the automaton mono
+     */
+    Mono<AhoCorasick> redisAutomaton(final ReactiveRedisTemplate<String, 
String> redisTemplate,
+                                     final String redisKey,
+                                     final List<String> ruleWords) {
+        return redisTemplate.opsForSet()
+                .members(redisKey)
+                .collectList()
+                // the redis driver emits on its own io threads; building the 
automaton is cpu
+                // bound, so hop to a bounded elastic worker before 
constructing it
+                .publishOn(Schedulers.boundedElastic())
+                .map(loaded -> AhoCorasick.of(merge(loaded, ruleWords)))
+                .subscribeOn(Schedulers.boundedElastic());
+    }
+
+    /**
+     * The automaton built from the rule words alone, deferred off the 
assembly thread so that
+     * the cpu bound construction never runs on the caller (the netty event 
loop).
+     *
+     * @param ruleWords the words of the rule
+     * @return the automaton mono
+     */
+    Mono<AhoCorasick> ruleWordsAutomaton(final List<String> ruleWords) {
+        return Mono.fromCallable(() -> AhoCorasick.of(ruleWords))
+                .subscribeOn(Schedulers.boundedElastic());
+    }
+
     /**
      * The words of the rule, separated by commas or by newlines, so that a 
word may contain spaces.
      *
      * @param words the words configured on the rule
      * @return the distinct words, in the order they were configured
      */
-    private List<String> parseWords(final String words) {
+    List<String> parseWords(final String words) {
         if (Objects.isNull(words) || words.trim().isEmpty()) {
             return Collections.emptyList();
         }
diff --git 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPluginTest.java
 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPluginTest.java
index 5b4c7a3c7a..445b9278a6 100644
--- 
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPluginTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word/src/test/java/org/apache/shenyu/plugin/ai/sensitive/word/SensitiveWordPluginTest.java
@@ -20,6 +20,7 @@ package org.apache.shenyu.plugin.ai.sensitive.word;
 import org.apache.shenyu.common.dto.RuleData;
 import org.apache.shenyu.common.dto.convert.rule.SensitiveWordHandle;
 import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.plugin.ai.sensitive.word.ac.AhoCorasick;
 import 
org.apache.shenyu.plugin.ai.sensitive.word.handler.SensitiveWordPluginDataHandler;
 import org.apache.shenyu.plugin.api.ShenyuPluginChain;
 import org.apache.shenyu.plugin.api.result.DefaultShenyuResult;
@@ -29,6 +30,8 @@ import org.apache.shenyu.plugin.base.utils.CacheKeyUtils;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
 import org.springframework.context.ConfigurableApplicationContext;
 import org.springframework.core.io.buffer.DefaultDataBufferFactory;
 import org.springframework.data.redis.core.ReactiveRedisTemplate;
@@ -40,13 +43,22 @@ import 
org.springframework.mock.web.server.MockServerWebExchange;
 import org.springframework.web.server.ServerWebExchange;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Scheduler;
+import reactor.core.scheduler.Schedulers;
 import reactor.test.StepVerifier;
 
 import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Consumer;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
@@ -277,6 +289,76 @@ public final class SensitiveWordPluginTest {
         verify(chain, never()).execute(any(ServerWebExchange.class));
     }
 
+    @Test
+    public void testTheDictionaryIsBuiltOffTheRedisIoThreads() {
+        final Scheduler redisIo = Schedulers.newParallel("simulated-redis-io");
+        try {
+            ReactiveRedisTemplate<String, String> redisTemplate = 
mock(ReactiveRedisTemplate.class);
+            ReactiveSetOperations<String, String> setOperations = 
mock(ReactiveSetOperations.class);
+            when(redisTemplate.opsForSet()).thenReturn(setOperations);
+            
when(setOperations.members(REDIS_KEY)).thenReturn(Flux.just("forbidden", 
"banned").subscribeOn(redisIo));
+            cacheRedisTemplate(redisTemplate);
+
+            final AtomicReference<String> buildThread = new 
AtomicReference<>();
+            StepVerifier.create(plugin.redisAutomaton(redisTemplate, 
REDIS_KEY, Collections.emptyList())
+                            .doOnNext(automaton -> 
buildThread.set(Thread.currentThread().getName())))
+                    .expectNextCount(1)
+                    .verifyComplete();
+
+            assertNotNull(buildThread.get(), "the automaton must still be 
built");
+            assertFalse(buildThread.get().startsWith("simulated-redis-io"),
+                    "the automaton must not be built on the redis io threads");
+        } finally {
+            redisIo.dispose();
+        }
+    }
+
+    @Test
+    public void testTheRuleWordsAutomatonBuildIsDeferred() {
+        final AtomicReference<String> buildThread = new AtomicReference<>();
+        try (MockedStatic<AhoCorasick> automaton = 
Mockito.mockStatic(AhoCorasick.class)) {
+            final AhoCorasick realEmpty = AhoCorasick.empty();
+            automaton.when(AhoCorasick::empty).thenReturn(realEmpty);
+            automaton.when(() -> AhoCorasick.of(any())).thenAnswer(invocation 
-> {
+                buildThread.set(Thread.currentThread().getName());
+                return realEmpty;
+            });
+            // assembly only: the cpu bound build must not run on the calling 
thread
+            plugin.ruleWordsAutomaton(Arrays.asList("forbidden", "banned"));
+            assertNull(buildThread.get(), "the fallback build must be deferred 
off the assembly thread");
+            // and the deferred build still works on subscription
+            
StepVerifier.create(plugin.ruleWordsAutomaton(Collections.singletonList("forbidden"))).expectNextCount(1).verifyComplete();
+        }
+    }
+
+    @Test
+    public void testCacheHitDoesNotParseTheRuleWords() {
+        mockRedisDictionary("forbidden");
+        cacheHandle(handle(customized -> customized.setWords("rule-only")));
+        // warm the cache with one miss
+        StepVerifier.create(plugin.doExecute(exchange("a clean request"), 
chain, null, ruleData)).verifyComplete();
+
+        final SensitiveWordPlugin spyPlugin = Mockito.spy(plugin);
+        StepVerifier.create(spyPlugin.doExecute(exchange("a clean request"), 
chain, null, ruleData)).verifyComplete();
+        verify(spyPlugin, never()).parseWords(anyString());
+    }
+
+    @Test
+    public void testCacheMissStillParsesTheRuleWords() {
+        final SensitiveWordPlugin spyPlugin = Mockito.spy(plugin);
+        cacheHandle(handle(customized -> customized.setWords("banned")));
+        ReactiveRedisTemplate<String, String> redisTemplate = 
mock(ReactiveRedisTemplate.class);
+        ReactiveSetOperations<String, String> setOperations = 
mock(ReactiveSetOperations.class);
+        when(redisTemplate.opsForSet()).thenReturn(setOperations);
+        when(setOperations.members(REDIS_KEY)).thenReturn(Flux.empty());
+        cacheRedisTemplate(redisTemplate);
+
+        // the miss path must still parse and enforce the rule words
+        StepVerifier.create(spyPlugin.doExecute(exchange("this request is 
banned"), chain, null, ruleData)).verifyComplete();
+        verify(spyPlugin).parseWords("banned");
+        verify(chain, never()).execute(any(ServerWebExchange.class));
+    }
+
     @SuppressWarnings("unchecked")
     private void mockRedisDictionary(final String... words) {
         ReactiveRedisTemplate<String, String> redisTemplate = 
mock(ReactiveRedisTemplate.class);

Reply via email to