Skip to content

fix(plugin): cache negative match results only when the candidate set is path-determined (#7977) - #7984

Merged
Aias00 merged 1 commit into
apache:masterfrom
konlue:fix/match-cache-negative-eligibility
Oct 27, 2026
Merged

Aias00 merged 1 commit into
apache:masterfrom
konlue:fix/match-cache-negative-eligibility

Conversation

@konlue

@konlue konlue commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

AbstractShenyuPlugin writes a negative (empty-id) selector or rule entry under (pluginName, path) whenever nothing matches. The blank-id branch in cacheSelectorData / cacheRuleData returns before the all-uri eligibility check, so a request that only missed because a header was absent poisons the path for the whole TTL (60 s by default): the next request carrying that header is short-circuited and doExecute is never called. defaultMatchRule / cacheRuleData have the same pattern.

In-process reproduction (real BaseDataCache / MatchDataCache, MockServerWebExchange, execute(...).block()):

  1. selector A requires header X-Tenant = a;
  2. GET /x without the header misses and caches an empty selector;
  3. GET /x with X-Tenant: a still does not match;
  4. clearing only the selector match cache makes step 3 match.

The same two-request sequence repros for a rule miss.

Change

  • Add isUriOnlyConditionList and candidate-set predicates. A cache entry is written only when every enabled candidate is determined by the path: no RESTful matching, and (for CUSTOM_FLOW selectors, and for rules) every condition is uri.
  • Empty condition lists are treated as path-determined: a CUSTOM_FLOW selector with no conditions never matches, and empty rule conditions are request-independent. Disabled entries are skipped because matching already ignores them.
  • The Objects.equals type check is null-safe, and a null type cannot reach it: filterSelector already unboxes selector.getType() for every enabled candidate while matching.
  • Gate all four cache call sites: a positive entry is only sound under the same path-determined candidate-set condition as a negative one ([BUG] URI-only positive match cache shadows more specific selectors with dynamic conditions #7981, reproduced by [ISSUE #7981] Fix dynamic match cache eligibility #7990).
  • The rule guard additionally requires the whole selector set to be path-determined. MatchDataCache keys rule entries by (pluginName, path) with no selector id, so a negative rule entry written for selector A would otherwise also suppress selector B on the same path. Positive cross-selector rule reuse remains [BUG] Rule match cache reuses a rule from another selector for the same request path #7976 and is not changed here.

Relationship to #7681

#7681 (fix(plugin): isolate match cache across config updates) proposes a broader generation-scoped match-cache redesign that also gates caching on "every enabled candidate is URI-only", and scopes rule cache keys by the selected selector. That PR is blocked on unrelated sync-layer clean* cascades (Aias00 CHANGES_REQUESTED, e2e red), not on the eligibility gate, which the review explicitly asks to keep.

This PR is the minimal, independently testable fix for #7977. If #7681 lands first, this change can be dropped; if this lands first, #7681 only needs its eligibility hunks reconciled.

Scope note (2026-10-26): #8106 landed on master while this was under review. This PR is now negative-entry-only (#7977): it reuses master's PATH_ONLY_OPERATORS / pathOnlyConditions for the negative selector and rule branches, and keeps the whole-selector-set guard for rule negatives because the rule cache key carries no selector id. Positive-cache eligibility is owned by #8106.

Tests

Nine new cases in AbstractShenyuPluginTest:

  • A selector miss caused by a missing header is not cached, and the next header-bearing request reaches doExecute.
  • The same two-request sequence for a rule miss.
  • A rule miss is not cached while the selector set is dynamic (path-only rule cache key).
  • positiveSelectorCacheMustNotShadowDynamicMatch: warming with X-Tenant: b caches no positive selector entry, and the next X-Tenant: a request executes the tenant selector.
  • positiveRuleCacheMustNotShadowDynamicMatch: the same sequence on the rule side.
  • positiveSelectorCacheStillAppliesToPathOnlyCandidates and positiveRuleCacheStillAppliesToPathOnlyCandidates: path-only candidate sets still cache positive entries, and a second identical request is served from the cache (getConditionList / getConditionDataList is never called again on the candidate).
  • Two positive controls for the negative-cache gate: path-only selector/rule candidate sets still cache negative entries and reuse them on a second request, so the optimization is preserved.
  • restfulSelectorCandidateShouldPreventResultCaching (parameterized over matched): a RESTful selector candidate keeps any selector entry out of the cache, for both a matching and a non-matching path.
  • restfulRuleCandidateShouldPreventResultCaching (parameterized over matched): the same on the rule side.

Red on master: the three negative-cache assertions fail with an empty SelectorData / RuleData already present. The two positive-cache shadow cases fail on the previous head with the pinned generic entry, exactly as #7990 reports. The two RESTful parameterized cases (four executions) fail with the candidate entry pinned once the matchRestful guard is removed. The cache-reuse assertions are live too: clearing the cache between the two requests makes both path-only controls fail with NeverWantedButInvoked. Green after the change.

Verification

# AbstractShenyuPluginTest 27/27
./mvnw -pl shenyu-plugin/shenyu-plugin-base -DskipTests=false -Dtest=AbstractShenyuPluginTest test

# Full module tests 310/310
./mvnw -pl shenyu-plugin/shenyu-plugin-base -DskipTests=false test

# Checkstyle clean, 0 violations
./mvnw -pl shenyu-plugin/shenyu-plugin-base checkstyle:check

Fixes #7977

Aias00
Aias00 previously approved these changes Oct 22, 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.

Review: #7984 fix(plugin): cache negative match results only when the candidate set is path-determined (#7977)

Verdict: approved. The diagnosis is right, the guard is placed at the right two call sites, and the tests are the correct shape — two red cases and two positive controls.

The bug is real and the fix addresses it

MatchDataCache keys negative entries by (pluginName, path) only. defaultMatchSelector / defaultMatchRule wrote those entries on any miss, including a miss caused purely by a missing header, so a single header-less request poisoned the path for the whole TTL. The new gates are:

  • isPathDeterminedSelectorSet — skips disabled selectors (matching already ignores them), rejects matchRestful, and rejects CUSTOM_FLOW selectors whose conditions are not all uri. Non-CUSTOM_FLOW selectors are correctly not condition-checked, since FULL_FLOW ignores conditions.
  • isPathDeterminedRuleSet — rejects matchRestful and any non-uri condition.
  • The rule path additionally requires the whole selector set to be path-determined. That is the part that is easy to miss and it is the part that matters: rules here comes from BaseDataCache.obtainRuleData(selectorData.getId()), i.e. the rules of one selector, while the cache key carries no selector id — so a negative rule entry written while selector matching is dynamic would also suppress a different selector on the same path. negativeRuleCacheMustRequirePathDeterminedSelectors pins exactly that, and it is the test I would have asked for.

Treating an empty condition list as path-determined is sound in both directions: a CUSTOM_FLOW selector with no conditions never matches (so a negative entry is stable), and empty rule conditions are request-independent. Either way no wrong entry can be produced.

Positive caching is untouched, which is correct — this PR should not absorb #7981.

Verified

  • defaultMatchRule now receives the plugin's full selector list. In execute the local selectors at the call site is the same list used by defaultMatchSelector, and rules is the selected selector's rule list, so both predicates see the sets they claim to.
  • MatchDataCache.cleanRuleDataData() exists (MatchDataCache:248), so the added teardown in clearCache compiles and the singleton caches are actually reset between tests.
  • The two positive controls (negativeSelectorCacheStillAppliesToPathOnlyCandidates, negativeRuleCacheStillAppliesToPathOnlyCandidates) assert a cached entry with a null id, so the optimisation is proven to still fire rather than merely not-crash.

Non-blocking

  1. Cost on the miss path. isPathDeterminedSelectorSet walks every selector for the plugin and isPathDeterminedRuleSet every rule of the selected selector, on each miss that reaches the cache write. That is cheap next to matching, but it runs on the hot path for plugins with many selectors; if this shows up in profiles, memoising the predicate per BaseDataCache generation would remove it entirely.
  2. Old entries survive the upgrade. Entries written before this change stay in MatchDataCache until their TTL expires (60 s by default), so the bug is still observable for one TTL after a rolling restart. Worth one line in the PR description so nobody reports it as "the fix does not work".
  3. isUriOnlyConditionList relies on URI_CONDITION_TYPE.equals(condition.getParamType()). A null paramType is treated as non-uri, which fails safe (no negative cache) — fine, just noting the direction.

@Aias00

Aias00 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Follow-up: the positive cache entry is still unsound (my approval stands, the fix is incomplete)

I approved this PR earlier, and the negative-entry gate it adds is correct and well tested. But #7990 (open, same root cause from #7981) demonstrates a case this PR does not cover, so please read it before merging either one.

defaultMatchSelector caches the matched selector whenever matchSelectorPair.getLeft() (uniqueness) is true, with no check on whether the candidate set is path-determined. That is exploitable the same way the empty entry was:

  1. request with X-Tenant: b -> only generic (URI-only) matches -> unique -> cached under (plugin, path)
  2. request with X-Tenant: a -> cache hit returns generic, tenant is never evaluated

The test in #7990 (selectorCacheShouldNotShadowDynamicCandidate) reproduces exactly this and fails on the current head. The same holds for rules.

So the condition this PR applies to the else branch needs to apply to the if (Objects.nonNull(selectorData)) branch as well - i.e. cache a positive entry only when isPathDeterminedSelectorSet(selectors) (and isPathDeterminedRuleSet(rules) for rules).

Suggested resolution, so the two PRs do not both land half a fix:

If you would rather land this PR as-is and follow up, that is acceptable for the negative-entry bug, but the positive-entry case should then get its own issue and be fixed before 2.7.2, because "the selector cache pins the first tenant's route for a path" is a correctness bug that is very hard to diagnose in production.

@konlue

konlue commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

You are right, and #7981 makes the positive case vivid — pushed the combined resolution you sketched:

  • Extended the gate to positive branches: The isPathDeterminedSelectorSet / isPathDeterminedRuleSet gate now applies to the positive branches as well, so both entry kinds require a path-determined candidate set. (The rule side keeps the extra whole-selector-set guard, since the rule key carries no selector id.)
  • Added regression from [ISSUE #7981] Fix dynamic match cache eligibility #7990: Adapted to this PR's helpers — warming with X-Tenant: b caches nothing for the path, and the subsequent X-Tenant: a request executes the tenant selector/rule. Both fail on the previous head with the pinned generic entry, exactly as selectorCacheShouldNotShadowDynamicCandidate does.
  • Added positive controls: Two tests pin that path-only sets still cache positive results, ensuring the gate does not disable the cache where it is sound.

Full module 306/306, checkstyle clean.

With this, the PR is a superset of #7990 (its hasOnlyUriConditions also treats empty condition lists as uncacheable and misses matchRestful and the rule-side selector guard), so per your suggestion #7990 could close once this lands.

@stone-98 the positive-entry gate and tests from your PR are incorporated here, thanks for the reproduction.

Aias00
Aias00 previously approved these changes Oct 23, 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.

Approving — this now covers what I asked for, and does it in the right place.

Verified in detail:

  • Positive and negative entries are gated for both the selector and the rule cache, so #7981's positive-cache variant is covered here and not only #7977's negative one.
  • defaultMatchRule takes the selector set and requires both sets to be path-determined, which is necessary because the rule cache key carries no selector id.
  • Objects.equals(SelectorTypeEnum.CUSTOM_FLOW.getCode(), selector.getType()) is safe: getCode() returns a primitive int, so the comparison unboxes.
  • MatchDataCache.cleanRuleDataData() exists on master, and adding cleanSelectorData() / cleanRuleDataData() to clearCache() is a genuine isolation fix — without it the new rule tests could read an entry left behind by an earlier test.
  • An empty condition list is correctly treated as cacheable: for a CUSTOM_FLOW selector the match outcome is request-independent either way, and for FULL_FLOW the condition list is not consulted at all.
  • Non-CUSTOM_FLOW selectors are correctly exempt from the condition check.

Two follow-ups:

  1. The matchRestful branch you added has no test. #7990 (the duplicate PR against #7981) has two parameterized tests that fit it exactly — restfulSelectorCandidateShouldPreventResultCaching and restfulRuleCandidateShouldPreventResultCaching. Please port them so the new branch is pinned.

  2. #7984 and #7990 are now the same change. Both touch the same four call sites in AbstractShenyuPlugin and are functionally equivalent, so they conflict textually and, worse, one could land on top of the other's gate. I recommend landing this one — it has the stronger positive/negative control coverage — and closing #7990; I am leaving that comment there now.

Nit (shared with #7990): selector.getType() is a boxed Integer, and a row with a null type is treated as non-CUSTOM_FLOW, which skips the condition gate entirely. If type can be null in any persisted data, make the guard null-safe (or treat null as CUSTOM_FLOW); otherwise a one-line comment stating that type is never null would be enough.

CI is currently all queued on this branch, so nothing to judge yet.

@Aias00

Aias00 commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Follow-up: #7990 is now closed as the duplicate, so this PR is the only place where these two test cases can land. Please port them before this is merged:

  1. restfulSelectorCandidateShouldPreventResultCaching and restfulRuleCandidateShouldPreventResultCaching — parameterized, covering both the selector and the rule branch of matchRestful. Right now the matchRestful branch you added has no coverage at all.
  2. The verify(candidate, never()).getConditionList() assertion inside the "uri-only results are cached" test. Without it the test only proves an entry was written, not that the second request actually hits the cache.

Everything else on this PR is approved; this is the last piece. CI on ec078c01 is still fully queued, so nothing has been verified on the current head yet either.

@konlue

konlue commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Both are in place.

  • RESTful cases: The two RESTful cases are ported parameterized over matched, covering the selector and rule branches of matchRestful; removing the guard makes all four executions fail with the pinned entry.
  • Path-only controls: The four path-only controls now spy the candidate, re-run the same request, and assert verify(candidate, never()).getConditionList() (rule: getConditionDataList()) together with assertSame on the cached entry, so they prove the second request is served from the cache rather than just that an entry was written.
  • Sanity check: Clearing the cache between the two requests makes both positive controls fail with NeverWantedButInvoked.
  • Null-type comment: The null-type comment is in as well.

The follow-up commit is 2 files, +94/-8. Local run: test class 27/27, module 310/310, checkstyle clean.

Understood on ec078c01 being queued — the new head carries all of the above and CI will be the first real run.

Aias00
Aias00 previously approved these changes Oct 23, 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.

Re-approving: the two tests requested in #7990 are now ported and the gate is complete.

Verified:

  • isPathDeterminedSelectorSet skips disabled selectors, rejects matchRestful and non-URI conditions on CUSTOM_FLOW selectors. Objects.equals(int, Integer) boxes the left side, so the comparison is safe.
  • The rule cache key still carries no selector id, which is why gating on both the selector set and the rule set is required; that reasoning is correct and documented in the comment.
  • verify(..., never()).getConditionList() on the second pass proves the cache really short-circuits matching, and the two @ParameterizedTest cases pin the RESTful gating for both hit and miss.
  • Empty condition list is treated as path-determined, which is right: such a selector matches every path, so a per-path entry stays consistent.

Note: the gate now walks the whole candidate set on every cache write (only on a miss), which is acceptable but worth keeping in mind if selector counts grow.

@yuluo-yx

Copy link
Copy Markdown
Member

hi @konlue please resolve the conflicts, thanks

@yuluo-yx

Copy link
Copy Markdown
Member

/hold

@github-actions github-actions Bot added the hold On hold, do not merge label Oct 24, 2026
@Aias00

Aias00 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

@konlue Heads-up: this PR is approved and every check is green, so the only thing blocking the merge is the merge conflict GitHub reports (mergeable: false).

Please rebase onto current master and push. I'll merge as soon as the PR reports clean again.

@konlue
konlue force-pushed the fix/match-cache-negative-eligibility branch from 43e225f to a82632b Compare October 24, 2026 15:06
@konlue

konlue commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto the latest master (d21ca8236) and resolved the conflicts. The only manual fix was the ArgumentMatchers.eq static import in AbstractShenyuPluginTest, which master had dropped while our RESTful and cache-reuse tests still use it; everything else merged cleanly.

master also added three new tests to this class, so the class now runs 30 tests (was 27), the full module is 319/319, and checkstyle is clean.

@yuluo-yx the conflicts are resolved — could you remove the /hold label? Thanks!

@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.

Requesting changes. The intent - do not cache positive matches whose candidate set is not path-determined - is correct, but isUriOnlyConditionList() only checks paramType == uri and does not inspect the operator. URI time predicates such as TimeAfter/TimeBefore have paramType == uri yet their match result depends on the current time, so this PR would still cache a positive result for a selector/rule carrying a time predicate and shadow the competing candidate as time advances. #8106 already implements the correct fix (a PATH_ONLY_OPERATORS whitelist that excludes TimeAfter/TimeBefore and header/query conditions) with comprehensive tests. Please either adopt that operator-allowlist approach here, or close this PR in favor of #8106. Happy to help align the implementation.

@konlue
konlue force-pushed the fix/match-cache-negative-eligibility branch from a82632b to ed8ace7 Compare October 26, 2026 07:41
@konlue

konlue commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

#8106 landed on master (9d07ba08) while this was under review, so I rebased and narrowed this PR to what master still lacks: negative-entry eligibility (#7977).

  • Removed duplicates and reused master's logic: Removed our duplicate positive gate and now reuse master's PATH_ONLY_OPERATORS / pathOnlyConditions for the negative branches. Selector entries require every enabled candidate to be path-only; rule entries require the same selector-set guard plus the rule set, because the rule cache key carries no selector id.
  • Remaining gap on master: Master's negative branches were still unconditional, so [BUG] Path-only negative match cache suppresses later requests that satisfy dynamic conditions #7977 remains reproducible there (if (matchSelectorPair.getLeft()) / if (matchRulePair.getLeft())).
  • Added tests: Added TimeAfter / TimeBefore negative cases for both selector and rule with a controlled clock, plus header/query/method and cross-selector rule cases. Two path-only controls keep the optimization pinned.
    • Red on master: 11 of 13 fail.
    • Green after the change: 13/13, module 334/334, checkstyle clean.
  • Kept existing positive work: The rewritten-path key from fix(plugin): preserve dynamic candidate matching after cache warmup #8106 is kept as-is; no duplicated positive work.

Verified:

./mvnw -pl shenyu-plugin/shenyu-plugin-base -DskipTests=false test  # 334/334
./mvnw -pl shenyu-plugin/shenyu-plugin-base checkstyle:check        # 0 violations

@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.

Re-reviewed after my earlier operator-eligibility change request. The current negative-cache logic uses the path-only operator allowlist, checks all enabled selector candidates, and additionally checks enabled rule candidates before caching a rule miss. TimeAfter/TimeBefore and dynamic header/query conditions now have regressions; path-only misses retain caching. No remaining blocking finding in this patch.

NegativeMatchCacheEligibilityTest and AbstractShenyuPluginTest passed all 30 tests, zero failures/errors/skips. Validation baseline: PR head plus a clean, uncommitted merge of master 52339b2 in the isolated worktree. No source was manually patched or pushed.

Used isolated worktrees and the Maven wrapper with JDK 21/project compiler settings. Checkstyle passed for the targeted Maven runs; RAT and JaCoCo were skipped. No full reactor/e2e pass is claimed.

@Aias00
Aias00 merged commit 46dfda3 into apache:master Oct 27, 2026
51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hold On hold, do not merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Path-only negative match cache suppresses later requests that satisfy dynamic conditions

3 participants