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);