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 61c9632f39 perf(plugin-base): batch namespace snapshot cache updates
and invalidation (#7645) (#7672)
61c9632f39 is described below
commit 61c9632f39137776bb59cbfcbdc0d2e9008f0e23
Author: Daming <[email protected]>
AuthorDate: Tue Oct 20 12:39:29 2026 +0800
perf(plugin-base): batch namespace snapshot cache updates and invalidation
(#7645) (#7672)
* perf(plugin-base): batch namespace snapshot cache updates and
invalidation (#7645)
applySelectorDataSnapshot/applyRuleDataSnapshot previously called the
per-row subscribe path for every incoming and stale entry, which ran one
list merge + sort per row and invalidated the match cache once per row.
On cold initialization or reconnect snapshots with thousands of rows, this
dominated sync-apply CPU and allocation.
Changes:
- BaseDataCache: add package-private removeSelectDataBatch and
removeRuleDataBatch that group deletions by plugin/selector bucket and
run a single computeIfPresent merge per bucket.
- CommonPluginDataSubscriber: applySelectorDataSnapshot now calls
refreshSelectorData(dataList) once and removeSelectDataBatch(stale) once;
applyRuleDataSnapshot mirrors that with refreshRuleData and
removeRuleDataBatch. Per-row plugin handler callbacks (handlerSelector,
handlerRule, removeSelector, removeRule) are preserved and wrapped in a
runGuarded helper that keeps the initial-sync abort semantics of the
previous subscribeDataHandler path.
- Match-cache invalidation is coalesced per affected pluginName, using the
broader removeSelectorData(pluginName)/removeRuleData(pluginName)
variants. Positive and negative entries for the affected plugin are
cleared together, so no stale match survives; other plugins' caches are
untouched.
Namespace isolation, ordering by sort key, empty-snapshot cleanup and
plugin-handler contracts are unchanged.
Tests:
- selectorSnapshotBatchesMergesAndPreservesSortOrder
- ruleSnapshotBatchesMergesAndPreservesSortOrder
- selectorSnapshotInvokesRemoveSelectorForEachStaleRow
- ruleSnapshotInvokesRemoveRuleForEachStaleRow
- snapshotCoalescesMatchCacheInvalidationPerAffectedPlugin
- selectorSnapshotHandlerFailurePropagatesDuringInitialSync
- selectorSnapshotHandlerFailureIsLoggedOutsideInitialSync
Ref: #7650
* fix(plugin-base): harden namespace snapshot against dangling and
malformed rows (#7645)
Address review findings on the batched snapshot apply:
- Blocking 1: dangling selectors/rules with a blank pluginName no longer
lost
their match-cache invalidation. collectAffectedPlugins drops blank names,
so add
invalidateDanglingSelectorMatchCache / invalidateDanglingRuleMatchCache
that
reproduce master's id-based cross-bucket sweep via the blank-name branch
of
MatchDataCache.
- Blocking 2: the batch cache write (refreshSelectorData/refreshRuleData)
is no
longer unguarded. A row with a null pluginName (selector) or null
selectorId
(rule) produced a null grouping key whose compute on a concurrent map
threw and
aborted the whole snapshot, causing a websocket resync loop. Malformed
rows are
now skipped up front with a warning so valid rows still converge;
collectAffectedPlugins
also tolerates null elements.
- Match-cache invalidation moved into a finally block so cleanup still runs
when a
handler aborts during initial sync (stale rows are already removed from
BaseDataCache and cannot be rediscovered on retry).
Adds tests for the dangling selector/rule sweep and malformed-row isolation.
---------
Co-authored-by: Luke Haochao Zhuang <[email protected]>
---
.../shenyu/plugin/base/cache/BaseDataCache.java | 64 +++++++
.../base/cache/CommonPluginDataSubscriber.java | 203 ++++++++++++++++++++-
.../base/cache/CommonPluginDataSubscriberTest.java | 197 ++++++++++++++++++++
3 files changed, 456 insertions(+), 8 deletions(-)
diff --git
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/BaseDataCache.java
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/BaseDataCache.java
index fed4420289..fc67d1143a 100644
---
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/BaseDataCache.java
+++
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/BaseDataCache.java
@@ -25,6 +25,8 @@ import org.apache.shenyu.common.dto.SelectorData;
import java.util.ArrayList;
import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -179,6 +181,44 @@ public final class BaseDataCache {
public void cleanSelectorDataSelf(final List<SelectorData>
selectorDataList) {
selectorDataList.forEach(this::removeSelectData);
}
+
+ /**
+ * Remove a batch of selectors with a single merge per affected plugin
bucket.
+ * Selectors without a plugin name are matched against every bucket,
mirroring
+ * {@link #removeSelectData(SelectorData)}.
+ *
+ * @param selectorDataList the selectors to remove
+ */
+ void removeSelectDataBatch(final List<SelectorData> selectorDataList) {
+ if (selectorDataList.isEmpty()) {
+ return;
+ }
+ final Map<String, Set<String>> idsByPlugin = new HashMap<>();
+ final Set<String> danglingIds = new HashSet<>();
+ for (SelectorData data : selectorDataList) {
+ if (Objects.isNull(data) || Objects.isNull(data.getId())) {
+ continue;
+ }
+ if (StringUtils.isBlank(data.getPluginName())) {
+ danglingIds.add(data.getId());
+ } else {
+ idsByPlugin.computeIfAbsent(data.getPluginName(), key -> new
HashSet<>()).add(data.getId());
+ }
+ }
+ if (!danglingIds.isEmpty()) {
+ selectorMap.keySet().forEach(pluginName ->
removeSelectDataByIds(pluginName, danglingIds));
+ }
+ idsByPlugin.forEach(this::removeSelectDataByIds);
+ }
+
+ private void removeSelectDataByIds(final String pluginName, final
Set<String> selectorIds) {
+ selectorMap.computeIfPresent(pluginName, (key, value) -> {
+ final List<SelectorData> result = value.stream()
+ .filter(selector ->
!selectorIds.contains(selector.getId()))
+ .collect(Collectors.toList());
+ return result.isEmpty() ? null : List.copyOf(result);
+ });
+ }
/**
* Obtain selector data list list.
@@ -239,6 +279,30 @@ public final class BaseDataCache {
public void cleanRuleDataSelf(final List<RuleData> ruleDataList) {
ruleDataList.forEach(this::removeRuleData);
}
+
+ /**
+ * Remove a batch of rules with a single merge per affected selector
bucket.
+ *
+ * @param ruleDataList the rules to remove
+ */
+ void removeRuleDataBatch(final List<RuleData> ruleDataList) {
+ if (ruleDataList.isEmpty()) {
+ return;
+ }
+ final Map<String, Set<String>> idsBySelector = new HashMap<>();
+ for (RuleData data : ruleDataList) {
+ if (Objects.isNull(data) || Objects.isNull(data.getId()) ||
Objects.isNull(data.getSelectorId())) {
+ continue;
+ }
+ idsBySelector.computeIfAbsent(data.getSelectorId(), key -> new
HashSet<>()).add(data.getId());
+ }
+ idsBySelector.forEach((selectorId, ruleIds) ->
ruleMap.computeIfPresent(selectorId, (key, value) -> {
+ final List<RuleData> result = value.stream()
+ .filter(rule -> !ruleIds.contains(rule.getId()))
+ .collect(Collectors.toList());
+ return result.isEmpty() ? null : List.copyOf(result);
+ }));
+ }
/**
* Obtain rule data list list.
diff --git
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriber.java
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriber.java
index c72c39e6e0..9c171358b4 100644
---
a/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriber.java
+++
b/shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriber.java
@@ -36,12 +36,16 @@ import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.lang.NonNull;
+import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Function;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
/**
* The type Common plugin data subscriber.
@@ -160,26 +164,100 @@ public class CommonPluginDataSubscriber implements
PluginDataSubscriber {
@Override
public void applySelectorDataSnapshot(final String namespaceId, final
List<SelectorData> dataList) {
- Set<String> selectorIds =
dataList.stream().map(SelectorData::getId).collect(Collectors.toSet());
- List<SelectorData> stale =
BaseDataCache.getInstance().getSelectorMap().values().stream()
+ final BaseDataCache cache = BaseDataCache.getInstance();
+ // A row without a plugin name yields a null grouping key in
refreshSelectorData, whose compute on a
+ // concurrent map throws and would abort the whole snapshot (and, over
a websocket, loop resyncs).
+ // The previous per-row subscribe path isolated such a row; skip it up
front so valid rows still converge.
+ final List<SelectorData> incoming = new ArrayList<>(dataList.size());
+ for (SelectorData data : dataList) {
+ if (Objects.nonNull(data) &&
Objects.nonNull(data.getPluginName())) {
+ incoming.add(data);
+ } else {
+ LOG.warn("skip malformed selector row without plugin name in
snapshot for namespace [{}]", namespaceId);
+ }
+ }
+ final Set<String> selectorIds =
incoming.stream().map(SelectorData::getId).collect(Collectors.toSet());
+ final List<SelectorData> stale =
cache.getSelectorMap().values().stream()
.flatMap(List::stream)
.filter(data -> namespaceId.equals(data.getNamespaceId()))
.filter(data -> !selectorIds.contains(data.getId()))
.collect(Collectors.toList());
- dataList.forEach(this::onSelectorSubscribe);
- stale.forEach(this::unSelectorSubscribe);
+ // Merge and sort once per affected plugin bucket instead of per
selector row.
+ if (!incoming.isEmpty()) {
+ cache.refreshSelectorData(incoming);
+ }
+ if (!stale.isEmpty()) {
+ cache.removeSelectDataBatch(stale);
+ }
+ try {
+ // Preserve per-item plugin handler callbacks and the initial-sync
failure semantics
+ // that the previous per-row subscribe path provided.
+ incoming.forEach(data -> runGuarded(data, () -> {
+ if (StringUtils.isNotBlank(data.getPluginName())) {
+ Optional.ofNullable(handlerMap.get(data.getPluginName()))
+ .ifPresent(handler ->
handler.handlerSelector(data));
+ }
+ }));
+ stale.forEach(data -> runGuarded(data, () -> {
+ // the concurrent handler map rejects null keys, and a
dangling selector carries no plugin name
+ if (StringUtils.isNotBlank(data.getPluginName())) {
+ Optional.ofNullable(handlerMap.get(data.getPluginName()))
+ .ifPresent(handler ->
handler.removeSelector(data));
+ }
+ }));
+ } finally {
+ // Invalidate even if a handler aborted during initial sync: the
stale rows are already gone from
+ // BaseDataCache above, so a retry cannot rediscover them and
their match cache would stay stale.
+
invalidateSelectorMatchCacheByPlugins(collectAffectedPlugins(incoming, stale,
SelectorData::getPluginName));
+ invalidateDanglingSelectorMatchCache(incoming, stale);
+ }
}
@Override
public void applyRuleDataSnapshot(final String namespaceId, final
List<RuleData> dataList) {
- Set<String> ruleIds =
dataList.stream().map(RuleData::getId).collect(Collectors.toSet());
- List<RuleData> stale =
BaseDataCache.getInstance().getRuleMap().values().stream()
+ final BaseDataCache cache = BaseDataCache.getInstance();
+ // A row without a selector id yields a null grouping key in
refreshRuleData, whose compute on a
+ // concurrent map throws and would abort the whole snapshot; skip it
up front so valid rows still converge.
+ final List<RuleData> incoming = new ArrayList<>(dataList.size());
+ for (RuleData data : dataList) {
+ if (Objects.nonNull(data) &&
Objects.nonNull(data.getSelectorId())) {
+ incoming.add(data);
+ } else {
+ LOG.warn("skip malformed rule row without selector id in
snapshot for namespace [{}]", namespaceId);
+ }
+ }
+ final Set<String> ruleIds =
incoming.stream().map(RuleData::getId).collect(Collectors.toSet());
+ final List<RuleData> stale = cache.getRuleMap().values().stream()
.flatMap(List::stream)
.filter(data -> namespaceId.equals(data.getNamespaceId()))
.filter(data -> !ruleIds.contains(data.getId()))
.collect(Collectors.toList());
- dataList.forEach(this::onRuleSubscribe);
- stale.forEach(this::unRuleSubscribe);
+ // Merge and sort once per affected selector bucket instead of per
rule row.
+ if (!incoming.isEmpty()) {
+ cache.refreshRuleData(incoming);
+ }
+ if (!stale.isEmpty()) {
+ cache.removeRuleDataBatch(stale);
+ }
+ try {
+ incoming.forEach(data -> runGuarded(data, () -> {
+ if (StringUtils.isNotBlank(data.getPluginName())) {
+ Optional.ofNullable(handlerMap.get(data.getPluginName()))
+ .ifPresent(handler -> handler.handlerRule(data));
+ }
+ }));
+ stale.forEach(data -> runGuarded(data, () -> {
+ if (StringUtils.isNotBlank(data.getPluginName())) {
+ Optional.ofNullable(handlerMap.get(data.getPluginName()))
+ .ifPresent(handler -> handler.removeRule(data));
+ }
+ }));
+ } finally {
+ // Invalidate even if a handler aborted during initial sync, so a
retry is not left with stale
+ // match-cache entries for rows already removed from BaseDataCache
above.
+ invalidateRuleMatchCacheByPlugins(collectAffectedPlugins(incoming,
stale, RuleData::getPluginName));
+ invalidateDanglingRuleMatchCache(incoming, stale);
+ }
}
@Override
@@ -402,6 +480,115 @@ public class CommonPluginDataSubscriber implements
PluginDataSubscriber {
}
}
+ /**
+ * Run a per-row callback for a snapshot apply, preserving the exception
semantics of
+ * {@link #subscribeDataHandler}: during initial sync failures abort the
snapshot, otherwise
+ * they are logged so subsequent rows still converge.
+ */
+ private <T> void runGuarded(final T data, final Runnable action) {
+ try {
+ action.run();
+ } catch (Exception e) {
+ if (InitialSyncApplication.isActive()) {
+ throw new IllegalStateException("Initial configuration
application failed", e);
+ }
+ LOG.error("snapshot handler error, classData: {}",
JsonUtils.toJson(data), e);
+ }
+ }
+
+ private <T> Set<String> collectAffectedPlugins(final List<T> incoming,
final List<T> stale,
+ final Function<T, String>
pluginNameOf) {
+ final Set<String> plugins = new HashSet<>();
+ incoming.forEach(data -> {
+ if (Objects.isNull(data)) {
+ return;
+ }
+ final String name = pluginNameOf.apply(data);
+ if (StringUtils.isNotBlank(name)) {
+ plugins.add(name);
+ }
+ });
+ stale.forEach(data -> {
+ if (Objects.isNull(data)) {
+ return;
+ }
+ final String name = pluginNameOf.apply(data);
+ if (StringUtils.isNotBlank(name)) {
+ plugins.add(name);
+ }
+ });
+ return plugins;
+ }
+
+ /**
+ * Mirror master's id-based cross-bucket sweep for dangling selectors that
carry no plugin name:
+ * {@link #collectAffectedPlugins} drops blank names, so without this a
deleted dangling selector would
+ * leave its cached matches (which may live under any plugin bucket)
behind. Delegates to the blank-name
+ * branch of {@link MatchDataCache#removeSelectorData(String, String)} and
+ * {@link MatchDataCache#removeRuleDataBySelector(String, String)}.
+ */
+ private void invalidateDanglingSelectorMatchCache(final List<SelectorData>
incoming, final List<SelectorData> stale) {
+ if (!selectorMatchConfig.getCache().getEnabled() &&
!ruleMatchCacheConfig.getCache().getEnabled()) {
+ return;
+ }
+ final MatchDataCache matchDataCache = MatchDataCache.getInstance();
+ Stream.concat(incoming.stream(), stale.stream())
+ .filter(Objects::nonNull)
+ .filter(data -> StringUtils.isBlank(data.getPluginName()))
+ .map(SelectorData::getId)
+ .filter(Objects::nonNull)
+ .forEach(id -> {
+ if (selectorMatchConfig.getCache().getEnabled()) {
+ matchDataCache.removeSelectorData(null, id);
+ }
+ if (ruleMatchCacheConfig.getCache().getEnabled()) {
+ matchDataCache.removeRuleDataBySelector(null, id);
+ }
+ });
+ }
+
+ /**
+ * Rule counterpart of {@link #invalidateDanglingSelectorMatchCache}: a
stale rule keyed by selectorId can
+ * carry a blank plugin name, so its cached matches are swept across every
bucket by rule id via the
+ * blank-name branch of {@link MatchDataCache#removeRuleData(String,
String)}.
+ */
+ private void invalidateDanglingRuleMatchCache(final List<RuleData>
incoming, final List<RuleData> stale) {
+ if (!ruleMatchCacheConfig.getCache().getEnabled()) {
+ return;
+ }
+ final MatchDataCache matchDataCache = MatchDataCache.getInstance();
+ Stream.concat(incoming.stream(), stale.stream())
+ .filter(Objects::nonNull)
+ .filter(data -> StringUtils.isBlank(data.getPluginName()))
+ .map(RuleData::getId)
+ .filter(Objects::nonNull)
+ .forEach(id -> matchDataCache.removeRuleData(null, id));
+ }
+
+ private void invalidateSelectorMatchCacheByPlugins(final Set<String>
pluginNames) {
+ if (pluginNames.isEmpty()) {
+ return;
+ }
+ final MatchDataCache matchDataCache = MatchDataCache.getInstance();
+ for (String pluginName : pluginNames) {
+ if (selectorMatchConfig.getCache().getEnabled()) {
+ matchDataCache.removeSelectorData(pluginName);
+ }
+ if (ruleMatchCacheConfig.getCache().getEnabled()) {
+ // selector membership changed, so any cached rule match under
this plugin may be stale
+ matchDataCache.removeRuleData(pluginName);
+ }
+ }
+ }
+
+ private void invalidateRuleMatchCacheByPlugins(final Set<String>
pluginNames) {
+ if (pluginNames.isEmpty() ||
!ruleMatchCacheConfig.getCache().getEnabled()) {
+ return;
+ }
+ final MatchDataCache matchDataCache = MatchDataCache.getInstance();
+ pluginNames.forEach(matchDataCache::removeRuleData);
+ }
+
@Override
public void onPluginRefresh(final List<PluginData> dataList) {
if (CollectionUtils.isEmpty(dataList)) {
diff --git
a/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriberTest.java
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriberTest.java
index 8487c594c4..bf49fbf85a 100644
---
a/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriberTest.java
+++
b/shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriberTest.java
@@ -584,4 +584,201 @@ public final class CommonPluginDataSubscriberTest {
private RuleData rule(final String id, final int sort) {
return
RuleData.builder().id(id).pluginName("divide").selectorId("selector").sort(sort).build();
}
+
+ @Test
+ public void selectorSnapshotBatchesMergesAndPreservesSortOrder() {
+ final SelectorData existing =
SelectorData.builder().id("existing").pluginName("divide")
+ .namespaceId("namespace-a").sort(2).build();
+ baseDataCache.cacheSelectData(existing);
+ final SelectorData first =
SelectorData.builder().id("s-3").pluginName("divide")
+ .namespaceId("namespace-a").sort(3).build();
+ final SelectorData second =
SelectorData.builder().id("s-1").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ final SelectorData replacement =
SelectorData.builder().id("existing").pluginName("divide")
+ .namespaceId("namespace-a").sort(4).build();
+
+ commonPluginDataSubscriber.applySelectorDataSnapshot("namespace-a",
List.of(first, second, replacement));
+
+ // one merge+sort per plugin bucket: sorted by the sort key,
replacement supersedes the existing row
+ assertEquals(List.of(second, first, replacement),
baseDataCache.obtainSelectorData("divide"));
+ verify(handler).handlerSelector(first);
+ verify(handler).handlerSelector(second);
+ verify(handler).handlerSelector(replacement);
+ }
+
+ @Test
+ public void ruleSnapshotBatchesMergesAndPreservesSortOrder() {
+ final RuleData existing =
RuleData.builder().id("existing").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(2).build();
+ baseDataCache.cacheRuleData(existing);
+ final RuleData first =
RuleData.builder().id("r-3").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(3).build();
+ final RuleData second =
RuleData.builder().id("r-1").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ final RuleData replacement =
RuleData.builder().id("existing").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(4).build();
+
+ commonPluginDataSubscriber.applyRuleDataSnapshot("namespace-a",
List.of(first, second, replacement));
+
+ assertEquals(List.of(second, first, replacement),
baseDataCache.obtainRuleData("selector-a"));
+ verify(handler).handlerRule(first);
+ verify(handler).handlerRule(second);
+ verify(handler).handlerRule(replacement);
+ }
+
+ @Test
+ public void selectorSnapshotInvokesRemoveSelectorForEachStaleRow() {
+ final SelectorData staleA =
SelectorData.builder().id("stale-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ final SelectorData staleB =
SelectorData.builder().id("stale-b").pluginName("divide")
+ .namespaceId("namespace-a").sort(2).build();
+ final SelectorData otherNamespace =
SelectorData.builder().id("other").pluginName("divide")
+ .namespaceId("namespace-b").sort(3).build();
+ baseDataCache.cacheSelectData(staleA);
+ baseDataCache.cacheSelectData(staleB);
+ baseDataCache.cacheSelectData(otherNamespace);
+
+ commonPluginDataSubscriber.applySelectorDataSnapshot("namespace-a",
List.of());
+
+ assertEquals(List.of(otherNamespace),
baseDataCache.obtainSelectorData("divide"));
+ verify(handler).removeSelector(staleA);
+ verify(handler).removeSelector(staleB);
+ }
+
+ @Test
+ public void ruleSnapshotInvokesRemoveRuleForEachStaleRow() {
+ final RuleData staleA =
RuleData.builder().id("stale-a").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ final RuleData staleB =
RuleData.builder().id("stale-b").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(2).build();
+ final RuleData otherNamespace =
RuleData.builder().id("other").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-b").sort(3).build();
+ baseDataCache.cacheRuleData(staleA);
+ baseDataCache.cacheRuleData(staleB);
+ baseDataCache.cacheRuleData(otherNamespace);
+
+ commonPluginDataSubscriber.applyRuleDataSnapshot("namespace-a",
List.of());
+
+ assertEquals(List.of(otherNamespace),
baseDataCache.obtainRuleData("selector-a"));
+ verify(handler).removeRule(staleA);
+ verify(handler).removeRule(staleB);
+ }
+
+ @Test
+ public void snapshotCoalescesMatchCacheInvalidationPerAffectedPlugin() {
+ final SelectorMatchCache selectorConfig = new SelectorMatchCache();
+ final RuleMatchCache ruleConfig = new RuleMatchCache();
+ selectorConfig.getCache().setEnabled(true);
+ ruleConfig.getCache().setEnabled(true);
+ commonPluginDataSubscriber = new
CommonPluginDataSubscriber(List.of(handler), eventPublisher, selectorConfig,
ruleConfig);
+ final MatchDataCache matches = MatchDataCache.getInstance();
+ // Preload positive and negative cache entries for two plugins.
+ matches.cacheSelectorData("/divide", selector("s-1", 1), 100, 100);
+ matches.cacheSelectorData("/divide-empty",
SelectorData.builder().pluginName("divide").build(), 100, 100);
+ matches.cacheRuleData("/divide", rule("r-1", 1), 100, 100);
+ final SelectorData otherPlugin =
SelectorData.builder().id("s-other").pluginName("rewrite")
+ .namespaceId("namespace-a").sort(1).build();
+ matches.cacheSelectorData("/rewrite", otherPlugin, 100, 100);
+
+ // Snapshot only touches the "divide" plugin; the "rewrite" cache must
remain.
+ final SelectorData incoming =
SelectorData.builder().id("s-2").pluginName("divide")
+ .namespaceId("namespace-a").sort(2).build();
+ commonPluginDataSubscriber.applySelectorDataSnapshot("namespace-a",
List.of(incoming));
+
+ assertNull(matches.obtainSelectorData("divide", "/divide"));
+ assertNull(matches.obtainSelectorData("divide", "/divide-empty"));
+ assertNull(matches.obtainRuleData("divide", "/divide"));
+ assertNotNull(matches.obtainSelectorData("rewrite", "/rewrite"));
+ }
+
+ @Test
+ public void selectorSnapshotHandlerFailurePropagatesDuringInitialSync() {
+ final SelectorData incoming =
SelectorData.builder().id("s-1").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ doThrow(new IllegalStateException("handler
failed")).when(handler).handlerSelector(incoming);
+ assertThrows(IllegalStateException.class, () ->
InitialSyncApplication.run(
+ () ->
commonPluginDataSubscriber.applySelectorDataSnapshot("namespace-a",
List.of(incoming))));
+ // cache write already published before the handler ran, mirroring the
pre-batch behavior
+ assertEquals(List.of(incoming),
baseDataCache.obtainSelectorData("divide"));
+ }
+
+ @Test
+ public void selectorSnapshotHandlerFailureIsLoggedOutsideInitialSync() {
+ final SelectorData first =
SelectorData.builder().id("s-1").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ final SelectorData second =
SelectorData.builder().id("s-2").pluginName("divide")
+ .namespaceId("namespace-a").sort(2).build();
+ doThrow(new IllegalStateException("handler
failed")).when(handler).handlerSelector(first);
+ assertDoesNotThrow(() ->
commonPluginDataSubscriber.applySelectorDataSnapshot("namespace-a",
List.of(first, second)));
+ verify(handler).handlerSelector(second);
+ assertEquals(List.of(first, second),
baseDataCache.obtainSelectorData("divide"));
+ }
+
+ @Test
+ public void danglingSelectorSnapshotSweepsMatchCacheAcrossBuckets() {
+ final SelectorMatchCache selectorConfig = new SelectorMatchCache();
+ final RuleMatchCache ruleConfig = new RuleMatchCache();
+ selectorConfig.getCache().setEnabled(true);
+ ruleConfig.getCache().setEnabled(true);
+ commonPluginDataSubscriber = new
CommonPluginDataSubscriber(List.of(handler), eventPublisher, selectorConfig,
ruleConfig);
+ final MatchDataCache matches = MatchDataCache.getInstance();
+
+ // a selector cached without a plugin name (dangling), plus
match-cache entries under a real plugin bucket
+ final SelectorData dangling =
SelectorData.builder().id("s-dangle").pluginName("").namespaceId("namespace-a").sort(1).build();
+ baseDataCache.cacheSelectData(dangling);
+ matches.cacheSelectorData("/dangle",
SelectorData.builder().id("s-dangle").pluginName("divide").build(), 100, 100);
+ matches.cacheRuleData("/dangle",
RuleData.builder().id("r-dangle").selectorId("s-dangle").pluginName("divide").build(),
100, 100);
+
+ // the snapshot drops the dangling selector; its blank name must still
sweep every match-cache bucket by id
+ commonPluginDataSubscriber.applySelectorDataSnapshot("namespace-a",
List.of());
+
+ assertNull(matches.obtainSelectorData("divide", "/dangle"));
+ assertNull(matches.obtainRuleData("divide", "/dangle"));
+ }
+
+ @Test
+ public void danglingRuleSnapshotSweepsMatchCacheAcrossBuckets() {
+ final SelectorMatchCache selectorConfig = new SelectorMatchCache();
+ final RuleMatchCache ruleConfig = new RuleMatchCache();
+ selectorConfig.getCache().setEnabled(true);
+ ruleConfig.getCache().setEnabled(true);
+ commonPluginDataSubscriber = new
CommonPluginDataSubscriber(List.of(handler), eventPublisher, selectorConfig,
ruleConfig);
+ final MatchDataCache matches = MatchDataCache.getInstance();
+
+ // a rule keyed by selectorId whose plugin name is blank, cached under
a real plugin bucket
+ final RuleData dangling =
RuleData.builder().id("r-dangle").selectorId("selector-a").pluginName("")
+ .namespaceId("namespace-a").sort(1).build();
+ baseDataCache.cacheRuleData(dangling);
+ matches.cacheRuleData("/dangle",
RuleData.builder().id("r-dangle").selectorId("selector-a").pluginName("divide").build(),
100, 100);
+
+ commonPluginDataSubscriber.applyRuleDataSnapshot("namespace-a",
List.of());
+
+ assertNull(matches.obtainRuleData("divide", "/dangle"));
+ }
+
+ @Test
+ public void malformedSelectorRowIsSkippedWithoutAbortingSnapshot() {
+ final SelectorData valid =
SelectorData.builder().id("s-ok").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ // no plugin name: would produce a null grouping key and NPE inside
the batch cache write
+ final SelectorData malformed =
SelectorData.builder().id("s-bad").namespaceId("namespace-a").sort(2).build();
+
+ assertDoesNotThrow(() ->
commonPluginDataSubscriber.applySelectorDataSnapshot("namespace-a",
List.of(malformed, valid)));
+
+ assertEquals(List.of(valid),
baseDataCache.obtainSelectorData("divide"));
+ verify(handler).handlerSelector(valid);
+ }
+
+ @Test
+ public void malformedRuleRowIsSkippedWithoutAbortingSnapshot() {
+ final RuleData valid =
RuleData.builder().id("r-ok").selectorId("selector-a").pluginName("divide")
+ .namespaceId("namespace-a").sort(1).build();
+ // no selector id: would produce a null grouping key and NPE inside
the batch cache write
+ final RuleData malformed =
RuleData.builder().id("r-bad").pluginName("divide").namespaceId("namespace-a").sort(2).build();
+
+ assertDoesNotThrow(() ->
commonPluginDataSubscriber.applyRuleDataSnapshot("namespace-a",
List.of(malformed, valid)));
+
+ assertEquals(List.of(valid),
baseDataCache.obtainRuleData("selector-a"));
+ verify(handler).handlerRule(valid);
+ }
}