Skip to content

fix(plugin): isolate match cache across config updates - #7681

Open
sunnysabor wants to merge 4 commits into
apache:masterfrom
sunnysabor:fix/match-cache-lifecycle
Open

sunnysabor wants to merge 4 commits into
apache:masterfrom
sunnysabor:fix/match-cache-lifecycle

Conversation

@sunnysabor

Copy link
Copy Markdown
Contributor

Follow-up to #7651.

The match cache can retain valid entries across configuration updates even when the selector/rule that produced them is no longer the winner. This change scopes rule matches to the selected selector and a BaseDataCache generation, so an in-flight request cannot repopulate a cache entry used after a later update. It only caches results when every enabled candidate uses stable URI conditions, uses the rewritten path as the key, and clears match entries in finally blocks if a plugin data handler throws. BaseDataCache serializes batch snapshot publication with incremental writes/deletes, cascades selector/plugin removal to child rules, and removes IDs that move between groups.

The generation is global to BaseDataCache: each config mutation makes earlier derived entries unreachable, and their bounded TTL reclaims them later. Config writes are infrequent, so they are serialized on the cache writer monitor; request reads remain unsynchronized. Dynamic header/query conditions skip match-result caching. The existing configurable TTL from #7664 remains the upper bound for unused old generations.

Validation:

  • 94 tests pass in the 11-module shenyu-plugin-base -am reactor, including deterministic refresh/delete/update interleavings and regressions for selector/rule priority, dynamic request conditions, selector scoping, rewritten paths, cascaded deletion, and handler exceptions.
  • Full repository Checkstyle passes across 213 modules.
  • Related reactor RAT passes on a clean tracked-source export with this patch.
  • git diff --check passes. A full repository clean install was not run.

public void cleanPluginData() {
public synchronized void cleanPluginData() {
pluginMap.clear();
cleanSelectorData();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] This cascade breaks the per-group refresh protocol of the sync layer and is the direct cause of the e2e failure on this PR. cleanPluginData() is not only a delete-all primitive: CommonPluginDataSubscriber.refreshPluginDataAll() (CommonPluginDataSubscriber.java:186-188) calls it, and the HTTP sync calls that whenever the PLUGIN group changes on admin — PluginDataRefresh.refresh() (shenyu-sync-data-center/shenyu-sync-data-http/.../refresh/PluginDataRefresh.java:70-78) does refreshPluginDataAll() and then re-caches only the plugins. On master cleanPluginData() cleared pluginMap only, so selectors/rules survived; with this line, every plugin enable/disable/update over HTTP sync empties selectorMap and ruleMap, and only plugins are refilled, so the gateway answers -107/-108 to every request until the next SELECTOR/RULE push. Reproduced by this PR's own CI: gateway log 13:28:59 update PLUGIN config (the e2e enabling loggingRocketMQ), no SELECTOR/RULE update afterwards, then 13:29:20 GET /http/order/findById -> {"code": -107, "message": "divide:Can not find selector"}. The websocket-mode batch of the same e2e passed because websocket PluginDataHandler.doUpdate only calls onSubscribe without any clear. Please drop this cascade (keep version++) and leave cross-map cleanup to the per-entity delete paths, which this PR already implements correctly in removePluginDataByPluginName.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on the current head (13fb0a1). cleanPluginData() now clears only pluginMap and advances the generation; plugin-owned selector/rule cleanup remains in removePluginDataByPluginName(). The full PR CI, including HTTP-sync E2E, is now green. Please re-review when convenient.

public void cleanSelectorData() {
public synchronized void cleanSelectorData() {
selectorMap.clear();
ruleMap.clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] Same protocol issue one level down: cleanSelectorData() is invoked by refreshSelectorDataAll() (CommonPluginDataSubscriber.java:212-216), which SelectorDataRefresh.refresh() (shenyu-sync-data-http/.../refresh/SelectorDataRefresh.java:70-78) calls on any SELECTOR-group change — it then re-caches only the selectors. With ruleMap.clear() here, every selector change over HTTP sync drops all rules until the next RULE push. In a full refresh it is worse: DataRefreshFactory.executor iterates its EnumMap in key ordinal order, and ConfigGroupEnum declares PLUGIN(1), RULE(2), SELECTOR(3) (shenyu-common/.../ConfigGroupEnum.java:33-48), so the sequence is PLUGIN -> RULE -> SELECTOR: the RULE step refills ruleMap, then the SELECTOR step clears it again and only selectors are refilled — ruleMap stays empty until an unrelated RULE change arrives. Please restrict this method to selectorMap (master semantics) and keep the rule cleanup in removeSelectData, which this PR already wires correctly (ruleMap.remove(selectorId)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on the current head (13fb0a1). cleanSelectorData() now clears only selectorMap and advances the generation; associated rules are removed in the per-selector removal path. The full PR CI, including HTTP-sync E2E, is now green. Please re-review when convenient.

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes. The core idea — generation-scoped match cache, rewritten-path keys, URI-only caching gate, finally-block cache cleanup — is well designed, but two cascades added to the clean* primitives break the sync layer's per-group refresh protocol. The e2e failure on this PR is that regression, not infrastructure flake; I traced it end to end below.

Blocking 1 — cleanPluginData() now clears selectors and rules (caused the e2e failure)

cleanPluginData() is not only a delete-all primitive. The sync layer uses it as the "clear" half of a per-group refresh:

  • admin pushes a PLUGIN-group change (e.g. enabling a plugin) -> gateway PluginDataRefresh.refresh() (shenyu-sync-data-center/shenyu-sync-data-http/src/main/java/org/apache/shenyu/sync/data/http/refresh/PluginDataRefresh.java:70-78) calls refreshPluginDataAll() and then re-caches only the plugins.
  • refreshPluginDataAll() -> cleanPluginData() -> with this PR also cleanSelectorData() -> selectorMap.clear() + ruleMap.clear().
  • On master, cleanPluginData() cleared pluginMap only, so selectors and rules survived a plugin-group refresh. With the cascade, any plugin enable/disable/update over HTTP sync leaves the gateway with zero selectors and zero rules until the next SELECTOR/RULE push — every request answers -107 Can not find selector / -108 Can not find rule.

Hard evidence from this PR's own failing e2e job (e2e-case-logging-rocketmq, run 38904376053, job 116989492155):

13:26:18  websocket-mode batch: "start loggingRocketMQ plugin" -> tests PASS at 13:27:03
13:27:22  compose switches gateway to http sync mode
13:28:04  gateway: update SELECTOR config (x3), update RULE config (x21)   <- re-registration
13:28:59  gateway: update PLUGIN config (x1)                                <- e2e enables loggingRocketMQ
          (no SELECTOR/RULE update after this point)
13:29:20  GET /http/order/findById?id=123 -> {"code": -107, "message": "divide:Can not find selector, please check your configuration!"}
          -> DividePluginTest.before:80 fails, 2 test cases fail

The websocket batch passed because websocket PluginDataHandler.doUpdate only calls onSubscribe per item — no clear — so only the HTTP-sync path exposes the bug. e2e-case (e2e-http-sync-compose, shenyu-e2e-case-http) would very likely have caught it too, but it was cancelled by the matrix fail-fast after the rocketmq job failed.

Blocking 2 — cleanSelectorData() now clears ruleMap

Same protocol issue one level down. refreshSelectorDataAll() -> cleanSelectorData() runs on any SELECTOR-group change (SelectorDataRefresh.java:70-78), and only selectors are re-cached — so every selector change over HTTP sync drops all rules until the next RULE push. In a full refresh it is worse: DataRefreshFactory.executor iterates its EnumMap in key ordinal order, and ConfigGroupEnum declares PLUGIN, RULE, SELECTOR (shenyu-common/.../ConfigGroupEnum.java:33-48), so the sequence is PLUGIN -> RULE -> SELECTOR: the RULE step refills ruleMap, the SELECTOR step then clears it again and nobody refills it — rules stay empty until an unrelated RULE change arrives.

Suggested fix

Keep the cascades in the per-entity delete paths only — this PR already implements them correctly there:

  • removePluginDataByPluginName removes the plugin, its selectors and its rules (correct for a plugin delete).
  • removeSelectData(pluginName, selectorId) removes the selector and does ruleMap.remove(selectorId) (correct for a selector delete).
  • removeRuleData already handles dangling rules.

What needs to change:

  1. cleanPluginData(): drop the cleanSelectorData() call, keep pluginMap.clear() + version++ (master semantics).
  2. cleanSelectorData(): drop ruleMap.clear(), keep selectorMap.clear() + version++ (master semantics).

Note the same root cause also affects the websocket full-refresh path: onPluginRefresh -> refreshPluginDataSelf -> cleanPluginDataSelf -> removePluginData per item, which with the cascade also drops each refreshed plugin's selectors and rules. Fixing the two clean* methods addresses that path as well.

What is good (and should be kept)

  • The generation-scoped match key (version + rewritten path, rule key scoped to the selected selector) is the right shape for the reported issue, and gating caching on "every enabled candidate uses stable URI conditions" plus skipping matchRestful is conservative in the right direction.
  • The per-entity cascades in removePluginDataByPluginName / removeSelectData / removeRuleData (including the dangling-selector/dangling-rule handling) are correct and are where the cascade belongs.
  • MatchCacheLifecycleTest covers the interesting lifecycles (priority replacement, dynamic competitors, cross-selector scoping, old-generation refill, handler exceptions). What is missing is coverage of the refresh protocol itself: please add a test that simulates refreshPluginDataAll() followed by plugin-only re-cache and asserts that obtainSelectorData / obtainRuleData still return their data (i.e. the exact scenario the e2e caught). A small test around DataRefreshFactory/PluginDataRefresh ordering would be even better.

CI note

The two red jobs are this PR's regression, not infrastructure: master's e2e is green (runs 38909074537 / 38924255915 both success, including the rocketmq case), and the failure mechanism is reproducible from the code path above. Once the cascades are removed, please rerun the e2e workflow — I would expect the rocketmq case to go green again.

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache-generation approach is sound. Putting BaseDataCache.version into the match key (version:path for selectors, selectorId.length:selectorId + version:path for rules) means an in-flight request can only populate the generation it started on, so a stale match result can never become visible to a newer configuration — that is the correct invariant, and it is better than trying to invalidate the trie/LRU on every change.

Also correct:

  • Caching is now restricted to !matchRestful && onlyUriConditions(...), which is the same safety rule as #8106: only a key that is fully determined by the URI path may be cached. Restful and header/query/time-predicate rules are recomputed every request.
  • getRawPath moving to RequestUrlUtils.getRewrittenRawPath keeps the cache key consistent with the rewritten path actually used for matching.
  • removePluginDataByPluginName now cascades into selectors and rules, and removeRuleData handles a blank selectorId by sweeping all buckets — both plug real leak paths.

One thing to confirm before merge: version-keyed entries and cache growth. Every configuration change mints a new key prefix, and the old-generation entries become unreachable. If the L1 LRU is bounded that is fine (they age out), but if the L2 trie retains them indefinitely this becomes a slow memory leak proportional to config-change rate. Please confirm the L2 store is bounded or is cleared on version bump — otherwise the fix trades a correctness bug for an unbounded-growth bug.

Minor: removePluginData calls removePluginDataByPluginName (which already increments version) and then increments again; cleanPluginDataSelf no longer cascades into selectors/rules. Both are harmless given version only needs to be monotonic, but the double increment is worth a comment so it does not look accidental.

LGTM once the cache-growth question is answered.

@sunnysabor
sunnysabor force-pushed the fix/match-cache-lifecycle branch from f2a21c9 to b19f360 Compare November 1, 2026 10:41
@sunnysabor

sunnysabor commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased on the latest upstream/master (92d16c2) and reran the focused plugin-base reactor. The cache-growth concern is addressed by the current implementation: MatchDataCache has one per-plugin selector map and one per-plugin rule map, each backed directly by WindowTinyLFUMap; there is no L2 trie. AbstractShenyuPlugin passes the configured maximumSize and TTL to each cache, and WindowTinyLFUMap applies Caffeine maximumSize(maximumSize) plus expireAfterWrite. Old generation keys therefore remain bounded by both limits and expire.

The targeted reactor passed: MatchCacheLifecycleTest, BaseDataCacheTest, and CommonPluginDataSubscriberTest (116 tests total, 0 failures/errors), with Checkstyle passing. The rebased branch preserves the refresh-protocol fix and contains no Markdown files.

@sunnysabor

Copy link
Copy Markdown
Contributor Author

The full PR workflow exposed four cache-test assertions that still queried the old unversioned /x key. They now use the current BaseDataCache generation key; rule lookups also include the selected selector ID. The dynamic-selector case now asserts the intended isolation: a miss for selector first is cached under first, and a subsequent request selecting second still matches its own rule.

Validation passed locally: focused positive/negative eligibility tests (27 tests) and the full 11-module plugin-base reactor (386 plugin-base tests, no failures; Checkstyle clean). Pushed as 13fb0a1b5; GitHub has started checks for the updated head.

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream Agent analysis

PR #7681 attempts to isolate match cache across config updates by scoping rule matches to selected selector and BaseDataCache generation. Bounded excerpts and e2e checks show success but full review pending.

Evidence

  • PR head SHA 13fb0a1; e2e checks success
  • Comment: rebased on 92d16c2; MatchDataCache uses per-plugin maps
  • Resource excerpt shenyu-source @13fb0a1b truncated AbstractShenyuPlugin

Recommended next steps

  • Review full patch for AbstractShenyuPlugin and MatchDataCache at head 13fb0a1
  • Confirm unit tests added for per-selector cache isolation
  • Validate with maintainers that finally-block cache clears are correct

Scope: bounded repository evidence; this is not a full code review or a claim that tests were run.

Task: c7b27e72-1cc2-4e7c-8dc7-74ca9b248f42 · Commit: 13fb0a1b5e8d83e9f5713967c7d5ca2d7ec6c54c

Aias00
Aias00 previously approved these changes Nov 3, 2026

@Aias00 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream Agent analysis

PR #7681 (head 13fb0a1) isolates match cache across config updates with version-scoped keys and selector-scoped negative caching. All partition reviews PASS, complete diff reviewed, CI green (68 checks). No HIGH/CRITICAL defects.

Evidence

  • Frozen review: all_parts_passed true, complete_diff true, CI passed 68 checks.
  • Two synthesis nodes reduce-1-0 and reduce-1-1 both PASS with no blocking findings.

Recommended next steps

  • Merge after standard approvals; monitor cache behavior in staging.

Scope: captured diff and GitHub check metadata only; no local tests were executed.

Task: eed0addd-80d2-4a92-8534-b2208a3c87e6 · Commit: 13fb0a1b5e8d83e9f5713967c7d5ca2d7ec6c54c

Review verdict: PASS

FINAL synthesis: all child partitions PASS, complete_diff true, CI green (68 checks). Cross-file cache isolation changes consistent, thread-safe, with regression tests. No HIGH/CRITICAL defects.

Publication: APPROVE (EXPLICIT_PASS)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants