Repository navigation
Conversation
…ation (apache#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: apache#7650
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent updates can be lost, failed snapshots can skip cleanup permanently, and dangling-rule matches can remain cached.
3 open findings
What changed in this PR
Optimizes ShenYu’s namespace snapshot processing to reduce repeated cache copying, sorting, and invalidation during synchronization.
Changes:
- Batches selector and rule updates and stale-row removals.
- Coalesces match-cache invalidation by affected plugin.
- Adds tests for sorting, namespace isolation, callbacks, and handler failures.
| File | Description |
|---|---|
| shenyu-plugin/shenyu-plugin-base/src/test/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriberTest.java | Adds snapshot regression tests. |
| shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/CommonPluginDataSubscriber.java | Batches snapshot application and invalidation. |
| shenyu-plugin/shenyu-plugin-base/src/main/java/org/apache/shenyu/plugin/base/cache/BaseDataCache.java | Adds grouped stale-row removal helpers. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| stale.forEach(this::unSelectorSubscribe); | ||
| // Merge and sort once per affected plugin bucket instead of per selector row. | ||
| if (!dataList.isEmpty()) { | ||
| cache.refreshSelectorData(dataList); |
| cache.refreshSelectorData(dataList); | ||
| } | ||
| if (!stale.isEmpty()) { | ||
| cache.removeSelectDataBatch(stale); |
| .ifPresent(handler -> handler.removeRule(data)); | ||
| } | ||
| })); | ||
| invalidateRuleMatchCacheByPlugins(collectAffectedPlugins(dataList, stale, RuleData::getPluginName)); |
Aias00
left a comment
There was a problem hiding this comment.
Review — batch namespace snapshot cache updates and invalidation
Strong PR. The performance argument is sound, the batching shape is right, and I verified the core claims against the actual dependency sources rather than taking them on trust. I found two blocking issues, both narrow but both reachable, plus a few notes.
I diffed the whole head tree against master, so everything below is checked against the real code, not the PR description.
🔴 Blocking 1 — dangling (blank pluginName) selectors no longer get their match cache invalidated
CommonPluginDataSubscriber.collectAffectedPlugins (lines 456-472) filters out blank names:
if (StringUtils.isNotBlank(name)) {
plugins.add(name);
}That filter is correct for the handler lookups (handlerMap is a ConcurrentHashMap built with Collectors.toConcurrentMap at line 73/90, so handlerMap.get(null) would NPE — good catch putting the guard there). But it also means a selector whose pluginName is blank contributes nothing to the match-cache invalidation set.
On master, that case was explicitly handled. removeCacheData → MatchDataCache.removeSelectorData(pluginName, selectorId) and removeRuleDataBySelector(pluginName, selectorId) both have a blank-name branch that sweeps every bucket (MatchDataCache.java:79-91 and :215-227). BaseDataCache keeps the same defensive handling in removeSelectData (BaseDataCache.java:140-149), and BaseDataCacheTest has a dedicated test for it (testRemoveSelectDataSweepsAllBucketsWhenPluginNameIsMissing, line 170). Your own removeSelectDataBatch preserves this for the BaseDataCache side (lines 208-210) — it's only the match-cache side that lost it.
And such entries are creatable: the only production writer is AbstractShenyuPlugin.cacheSelectorData (:156-175), which keys by selectorData.getPluginName() and calls MapUtils.computeIfAbsent(SELECTOR_DATA_MAP, pluginName, ...) — a "" bucket is reachable.
Consequence: a snapshot that deletes a dangling selector leaves its entry in MatchDataCache, so the request path can short-circuit to a match for a selector that no longer exists in BaseDataCache.
Ask: either treat blank as "all buckets" the way MatchDataCache already does, or — if blank-pluginName selectors are now considered impossible — drop the defensive branches in BaseDataCache.removeSelectData / MatchDataCache too so the intent is uniform, and say so in the PR. As it stands the module is internally inconsistent. Please add a test either way.
The same gap applies to RuleData with a blank pluginName in invalidateRuleMatchCacheByPlugins (collectAffectedPlugins(dataList, stale, RuleData::getPluginName)), where master's invalidateRuleMatchCache reached removeRuleData(blank, id)'s sweep branch.
🔴 Blocking 2 — the batch cache write lost the per-row exception isolation subscribeDataHandler provided
On master, applySelectorDataSnapshot was:
dataList.forEach(this::onSelectorSubscribe); // per row, inside subscribeDataHandler's try/catchsubscribeDataHandler (lines 288-303) catches Exception, rethrows during InitialSyncApplication, and otherwise logs and moves on. So a single malformed row was isolated: the rest of the snapshot still converged, and BaseDataCache ended up correct for every good row.
In the head, cache.refreshSelectorData(dataList) (lines 173-175) and cache.refreshRuleData(dataList) (lines 203-205) are unguarded. A row with a null pluginName produces a null key from Collectors.groupingBy (a HashMap permits it), and then next.compute(null, ...) on a ConcurrentHashMap throws. That NPE now propagates out of applySelectorDataSnapshot and aborts everything after it — the stale removal, all handler callbacks, and the match-cache invalidation. BaseDataCache itself is left untouched (the selectorMap = next assignment at BaseDataCache.java:422 is after the forEach), but the whole snapshot is dropped.
Note the NPE contract itself is pre-existing and deliberately tested — BaseDataCacheTest.invalidCandidatesDoNotPublish (lines 392-401) asserts exactly this for all three refresh*Data methods. On master that path was only reachable from onSelectorRefresh. This PR makes it reachable from the snapshot apply, without the isolation.
How far the NPE travels, verified end to end:
ShenyuWebsocketClient.onMessage (:462-479) catch (RuntimeException) -> handleSyncFailure(...)
WebsocketDataHandler.snapshot (:103-113) no try/catch
AbstractDataHandler.handleSnapshot (:67-73) no try/catch
SelectorDataHandler.doSnapshot (:49-54) no try/catch
handleSyncFailure (ShenyuWebsocketClient.java:493-505) increments the consecutive-failure counter and calls this.close() — i.e. a websocket reconnect for full resync. Because the same bad snapshot is re-delivered on reconnect, this becomes a reconnect loop rather than a one-off. On master the same row was logged and the connection stayed up with a correct cache.
Reachability: the row only needs a non-null SelectorData with a missing pluginName field. SelectorDataHandler.doSnapshot's namespace check (!namespaceId.equals(data.getNamespaceId())) passes as long as namespaceId is present, and the payload is Gson-deserialized with no field validation (SelectorDataHandler.convert, :38-40).
Ask: wrap the batch cache writes (and collectAffectedPlugins, which is also unguarded — pluginNameOf.apply(data) NPEs on a null element, whereas master's Optional.ofNullable(classData).ifPresent(...) at :291-296 tolerated them) in the same runGuarded treatment, or filter/skip invalid rows up front with a warning. Keep the initial-sync rethrow semantics if you want fail-fast at startup.
✅ What I verified as correct
Batching machinery
refreshSelectorData/refreshRuleDataare pre-existing on master (used byonSelectorRefresh/onRuleRefresh), already tested for atomicity and unrelated-bucket retention (BaseDataCacheTest.selectorRefreshRetainsMissingDataAndPublishesSortedLists,:352-370), and they publishList.copyOf(...)immutable snapshots. Reusing them is the right call.removeSelectDataBatch(BaseDataCache.java:192-212) is a faithful group-by ofremoveSelectData: skips null data and null ids, sweeps every bucket for blank plugin names, onecomputeIfPresentper affected bucket, empty result removes the bucket. Matches the existing single-row semantics exactly.removeRuleDataBatch(:288-305) is keyed byselectorId(the actualruleMapkey) and is more defensive than master'sremoveRuleData, which would NPE on a nullselectorIdviacomputeIfPresent(null, ...); the batch version skips those rows. Fine — stale rows come fromruleMap.values()so theirselectorIdis a real map key anyway.
Exception semantics
runGuarded(lines 440-454) genuinely reproducessubscribeDataHandler. I checkedInitialSyncApplication.run(shenyu-common/.../InitialSyncApplication.java:44-62): it sets theThreadLocalbeforeaction.run()and does not swallow, andisActive()isThreadLocal-based, soselectorSnapshotHandlerFailurePropagatesDuringInitialSync's expectation is sound.- Per-row ordering is preserved. Master's
updateCacheDataalso wrote the cache before invoking the handler (cacheSelectData→handleSelectorData, lines 320-323), so "cache first, then handler" still holds per row; the only change is that all cache writes now precede all handlers. For handlers that readBaseDataCachethis is strictly more consistent.
Match-cache coalescing
- The coarser invalidation is safe.
removeSelectorData(pluginName)isSELECTOR_DATA_MAP.remove(pluginName)(MatchDataCache.java:69-71), which subsumes master'sremoveSelectorData(plugin, id)+removeEmptySelectorData(plugin);removeRuleData(pluginName)isRULE_DATA_MAP.remove(pluginName)(:185-187), subsumingremoveRuleDataBySelector+removeEmptyRuleData. No stale entry can survive — the cost is a wider flush, which is the deliberate trade the code comment documents.
Tests
clearCaches()in@BeforeEach(lines 430-436) clears both theBaseDataCacheandMatchDataCachesingletons, so the newMatchDataCacheassertions can't leak between tests.- Coverage is good: sort-order preservation, upsert-replacement, per-row handler invocation, stale removal, namespace isolation, match-cache coalescing including "unrelated plugin's cache survives", and both initial-sync and post-initial-sync failure paths.
Concurrency claim — your PR description says the swap race "exists on onSelectorRefresh today and is not widened by this change". I confirmed that: refreshSelectorData is already called from onSelectorRefresh on master (CommonPluginDataSubscriber.java:511-517 in head, identical on master). Accurate.
📝 Non-blocking notes
applyPluginDataSnapshotis now the odd one out — still per-rowonSubscribe/unSubscribewhile selector and rule are batched. Your justification (pluginMap.putis O(1), no per-row sort) is correct, so this is fine, but a short TODO would help the next reader.- The wider match-cache flush has a real cost. A snapshot touching one selector now drops that plugin's entire selector and rule match cache. On a plugin with a large cache that's a much bigger flush per snapshot than master. Acceptable given the batching goal; just worth knowing it's not free.
JsonUtils.toJson(data)inrunGuarded's error path would log"null"for a null row. Cosmetic.
CI
Both red checks are unrelated to this PR:
e2e(3s) is the fail-fast aggregation job.e2e-storage (e2e-opengauss-compose)failed onDividePluginTest.testDivide:105 endpoint '/anything' not exists.DividePluginCasesuseshttpbin.orgas a live upstream (DividePluginCases.java:69-70), so this is the same external-site flake I attributed while reviewing #7677. On master, run38836735684failed the identical assertion on postgres while opengauss passed (e2e-storage (shenyu-e2e-case-storage, e2e-postgres-compose) | failure, opengauss| success) — the failure migrates between databases, which rules out a database-specific regression. The three master runs since then (8f3ee32e,66d7d954,3af7bfc6) are all green. This PR touches no e2e code.
Summary
Request changes for the two items above. Both are small fixes: keep the blank-pluginName sweep for match-cache invalidation, and give the batch cache write the same per-row isolation the handler callbacks already have. Everything else — the batching shape, the immutable published snapshots, the exception semantics, the test isolation — is correct, and the perf motivation is real. Happy to re-review once those land.
…ormed rows (apache#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.


Which issue is this PR closes?
Closes #7645.
Tracker: #7650.
What does this PR change?
CommonPluginDataSubscriber.applySelectorDataSnapshot/applyRuleDataSnapshotpreviously routed every incoming and every stale row through the per-row subscribe path. For each row that meant:ConcurrentMap.computeonselectorMap/ruleMapthat rebuilt a fullArrayList, ranList.sort(...)and produced a freshList.copyOf(...)— one merge+sort per row instead of one per plugin/selector bucket;removeSelectorData(plugin, id)+removeEmptySelectorData(plugin)and the rule-side equivalents) per row.With a cold-init or reconnect snapshot of a few thousand rows this dominated sync-apply CPU and allocation, even though
BaseDataCachealready exposed groupedrefreshSelectorData/refreshRuleDatahelpers used byonSelectorRefresh/onRuleRefresh.This PR reuses that machinery on the snapshot path and adds a symmetric batch removal helper.
BaseDataCacheTwo new package-private helpers:
removeSelectDataBatch(List<SelectorData>)groups the batch bypluginNameand does onecomputeIfPresentper bucket, filtering by an idHashSet. Dangling selectors (blank plugin name) still match every bucket, mirroringremoveSelectData(SelectorData).removeRuleDataBatch(List<RuleData>)groups byselectorIdwith the same one-merge-per-bucket shape.CommonPluginDataSubscriberapplySelectorDataSnapshot(namespaceId, dataList):refreshSelectorData(dataList)— grouped by plugin, merge+sort once per plugin bucket.removeSelectDataBatch(stale)— grouped by plugin, single merge per bucket.dataListandstaleto fire per-row plugin-handler callbacks (handlerSelector/removeSelector). Callbacks are wrapped in a newrunGuardedhelper that preserves the exception semantics ofsubscribeDataHandler: rethrow asIllegalStateExceptionduringInitialSyncApplication, otherwise log and continue so the rest of the snapshot still converges.pluginNameviainvalidateSelectorMatchCacheByPlugins— uses the broaderremoveSelectorData(pluginName)/removeRuleData(pluginName)variants, so both positive and negative entries under the touched plugins are cleared together. Other plugins caches are untouched.applyRuleDataSnapshotmirrors that shape withrefreshRuleData/removeRuleDataBatch/invalidateRuleMatchCacheByPlugins.Plugin-handler contracts, deletion semantics, ordering by
sort, namespace isolation and empty-snapshot cleanup are unchanged.applyPluginDataSnapshotis untouched:pluginMap.putis already O(1) and there is no per-row sort to coalesce.Ordering vs. concurrent incremental updates
The batch cache write is a single reference swap (
selectorMap = next/ruleMap = next) built from the current map — the same shape asonSelectorRefresh. Per-row handler callbacks run after the swap so they observe the fully merged snapshot. ConcurrentonSelectorSubscribecalls arriving mid-snapshot can still race with the swap; that race exists ononSelectorRefreshtoday and is not widened by this change.Tests
New unit tests in
CommonPluginDataSubscriberTest:selectorSnapshotBatchesMergesAndPreservesSortOrder/ruleSnapshotBatchesMergesAndPreservesSortOrder— incoming rows out of order plus a replacement for an existing id must produce a single sorted list per bucket, and every row must still hithandlerSelector/handlerRule.selectorSnapshotInvokesRemoveSelectorForEachStaleRow/ruleSnapshotInvokesRemoveRuleForEachStaleRow— empty incoming snapshot clears only the stale rows in the target namespace, keeps other namespaces, and callsremoveSelector/removeRuleper stale row.snapshotCoalescesMatchCacheInvalidationPerAffectedPlugin— positive and negative match-cache entries under the affected plugin are cleared; entries under an unaffected plugin survive.selectorSnapshotHandlerFailurePropagatesDuringInitialSync— a throwing handler duringInitialSyncApplication.run(...)aborts the snapshot as before.selectorSnapshotHandlerFailureIsLoggedOutsideInitialSync— outside initial sync the failure is logged and the remaining rows still converge.Existing
testSelectorSnapshotReplacesRowsAndRemovesOnlyStaleNamespaceRowsandtestRuleSnapshotReplacesRowsAndRemovesOnlyStaleNamespaceRowscontinue to pass unchanged, so the namespace-isolation contract is preserved.Module run:
Measurement status
The tracker issue asks for a before/after fixed-load comparison (1k / 10k+ configs, cold init and reconnect, business traffic running). This environment does not have a load generator, controlled upstream or JFR profiling harness, so no numeric before/after is attached here. The change is source-grounded: it removes an O(n) merge+sort per row and an O(n) match-cache invalidation per row on a path that already had a batched alternative, and it does not touch request-path code. Reviewers with a benchmark rig should be able to reproduce the sync-apply delta directly.
Compatibility
No public API change.
removeSelectDataBatch/removeRuleDataBatchare package-private. Behavioural differences are limited to:onSelectorRefresh), instead of interleaved per row.Neither changes routing decisions.