Skip to content

[ISSUE #8037] Validate sticky session configuration at the management plane and cover cross-replica verification - #8305

Merged
Aias00 merged 3 commits into
apache:masterfrom
konlue:feat/8037-sticky-management-validation
Nov 10, 2026
Merged

Aias00 merged 3 commits into
apache:masterfrom
konlue:feat/8037-sticky-management-validation

Conversation

@konlue

@konlue konlue commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Part of #8037.

Problem

Phase 1 and 2 delivered the affinity itself (#8098, #8212), but the sticky section of a divide rule handle is never validated by the management plane: the admin rule service only inspects canary.*, so sticky passes through Gson's lenient binding straight into the store — an unknown onUnavailable spelling silently behaves as REBIND at runtime, a non-positive maxAgeSeconds or blank secrets silently disable the feature, and nothing tells the operator why. The test suite also exercised the affinity only through a single instance, leaving the operational assumption — that any gateway replica sharing the rule's secrets honors another replica's binding — documented but unverified.

Change

This PR adds StickySessionConfigValidator, a management-plane validator for the sticky section of an enabled configuration: onUnavailable must be exactly REBIND or STRICT (the runtime compares case-sensitively, so other spellings are rejected instead of silently degrading), maxAgeSeconds must be positive, cookieName must be a non-blank string (an explicit null is not treated as equivalent to omitting it), an enabled configuration requires at least one non-blank secret, and a configured deterministic key requires a non-blank name and, when type is present, HEADER or COOKIE (the runtime treats anything else as a header lookup). Structure is checked before Gson can coerce values, so a non-object sticky, a non-integer lifetime, a non-array secret list or a non-string field fails with a message naming the sticky.* field instead of the generic parse error, and "enabled":1 no longer escapes as a raw IllegalStateException. The admin rule service calls it for every divide rule write path — createOrUpdate, create, update, registerDefault, the configuration import and the namespace sync — so the save fails with a message naming the offending sticky.* field. CanaryConfigValidator.parseHandle also guarantees and documents a non-null result, so callers can dereference it directly without a redundant guard.

A disabled configuration is exempt from value validation. The gateway activation checks ignore it entirely, so none of its values can affect a request, and rejecting it would stop an operator from editing an unrelated field of an already stored rule. Structural JSON type checks still run, because those shapes cannot be bound at all.

The gateway runtime keeps its tolerance for values: a semantically unusable configuration persisted before this validation still degrades to plain load balancing, so an upgrade does not turn a stored bad value into a request failure — the write path is where new mistakes are stopped. Structural JSON type errors are rejected when the handle is parsed, as the canary section already does. Documentation gains a management-validation section and a cross-replica operation section stating that affinity is portable only across replicas sharing the rule's secrets, and that a key rollout keeps working in one direction only: a replica that already holds the new key still accepts bindings signed with the old key, while a replica that still holds only the old key cannot verify a binding signed with the new one and starts a new session instead.

Tests

StickySessionConfigValidatorTest covers every rejection branch (unknown and lower-cased policy, non-positive lifetime, blank and explicit-null cookie name, enabled without secrets, blank secrets, key without name, key without type) plus both valid key types, the valid case and the disabled pass-through case; an agreement test asserts that the validator accepts exactly the configurations the shared runtime activation predicate accepts. CanaryConfigValidatorTest covers a null sticky section, the structural type errors above, a valid structured section and the parser's non-null contract. RuleServiceTest adds the admin-side behavior: an invalid sticky section fails the create, the update, the registration and both configuration import overloads without writing a rule or publishing an event, a valid section is stored unchanged, and a disabled section does not block the write. DivideStickySessionAffinityTest adds the replica scenarios: a binding signed by one replica is honored by another replica sharing the secrets, a replica still signing with the old key is honored during a rotation window by a replica that already holds the new key, and a replica without the shared secrets rejects the foreign binding.

Verification

mvn -pl shenyu-common,shenyu-plugin/shenyu-plugin-proxy/shenyu-plugin-divide,shenyu-admin -am clean test -DskipTests=false passes the common suite (683), the divide suite (162) and the admin rule service suite (58) clean; the full admin suite runs 2691 tests with the single pre-existing flaky ShenyuClusterServiceTest.testSelectMasterErrorCountTracking failing (cluster-master error counting, unrelated to this change; it passes 9/9 in isolation). Checkstyle reports zero violations in the three modules. On the previous code the new validator and parse tests are red: the explicit-null cookie name, the null key.type, the activation-agreement test and the structural type-message test.

…gement plane and cover cross-replica verification

@wy471x wy471x 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 direction is right: the validator sits next to CanaryConfigValidator, is wired into every RuleServiceImpl write path that previously checked canary only, and the docs state the management/runtime split clearly. Findings below are about cases where an "enabled but silently inert" configuration still passes the new validation, which is precisely what this PR set out to eliminate.

  1. StickySessionConfigValidator#validateCookieName - explicit null still passes and silently disables affinity:
if (Objects.nonNull(cookieName) && StringUtils.isBlank(cookieName))

Gson overwrites the field initializer with null for an explicit JSON null, and StickySessionAffinity#isActive treats a blank cookie name as inactive. So {"enabled":true,"secrets":["s"],"cookieName":null} is persisted and the gateway never writes a cookie - the exact failure mode the PR description rejects. StringUtils.isBlank is null-safe, so the guard can go and null can be rejected with the same message ("omit it to use the default" and "explicit null" are not equivalent here).

  1. validateKey checks only name, not type. Gson maps an unrecognized enum constant to null, and DeterministicKeyExtractor#extract falls through to the header path for anything that is not COOKIE:
if (DeterministicKeyConfig.KeyType.COOKIE == config.getType()) { ... } else { headers.getFirst(name) }

So {"key":{"name":"X","type":"cookie"}} is accepted and then reads a request header, silently picking the wrong request location - the same class of degradation rejected for onUnavailable.

  1. The documented "error naming the offending field (sticky.…)" does not hold for type mismatches. RuleServiceImpl routes the handle through CanaryConfigValidator.parseHandle, which validates JSON types for canary only; "sticky":[], "maxAgeSeconds":1.5 or "secrets":"s" therefore surface as Invalid Divide handle JSON instead of naming sticky.maxAgeSeconds / sticky.secrets. A sticky type pre-check in the same object(root, "sticky") style as canary would close that gap, and would also give the validator a JsonObject to validate against, the way CanaryConfigValidator deliberately checks types "before Gson can coerce values".

  2. Coverage of the paths this PR changes: importData(List) and importData(namespace, List) were both renamed and now validate sticky, but testInvalidStickyNeverWritesOrPublishes asserts nothing about either, while the canary analogue does. The namespace overload is the more interesting one because it silently skips rules whose selector is unmapped, so a guard regression there would be invisible.

  3. StickySessionConfigValidator re-encodes the activation predicate already implemented in StickySessionAffinity#isActive (enabled, positive lifetime, non-blank cookie name, at least one non-blank secret), kept in sync only by a comment. Adding a field means editing both places with no test asserting they still agree; a shared isUsable() on StickySessionConfig (or moving it to shenyu-common next to the config) would give the predicate one owner.

  4. Minor naming: validateDivideHandle and validateRuleHandle are near-synonyms called back to back in five methods, and the Objects.nonNull(divide) guard added around the sticky call is dead - parseHandle already returns non-null or throws for any non-blank handle.

The two "another replica" tests are fine; the rollout one in particular exercises the one-directional key rotation the docs describe.

@konlue

konlue commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @wy471x — all six points are addressed in the latest commit.

  1. cookieName: the Objects.nonNull guard is gone; StringUtils.isBlank now rejects null and blank with the same message. One nuance: on the JSON path GsonUtils.StringTypeAdapter already materializes an explicit null as "", so the existing message fired there; the actual gap was a directly constructed POJO, which explicitNullCookieNameIsRejected now covers. The JSON-level case is also asserted through the admin write path.

  2. key.type: validateKey rejects a null type (exactly what Gson produces for an unknown enum constant), and the JSON pre-check rejects unknown spellings with the original value: sticky.key.type must be HEADER or COOKIE, got: cookie. Tests cover null and lower-case.

  3. Type mismatches now name the field. A new StickySessionConfigValidator.validateJson(JsonElement) runs inside CanaryConfigValidator.parseHandle before Gson, so sticky:[], maxAgeSeconds:1.5, secrets:"s", key:[] and non-string fields produce sticky.… messages instead of Invalid Divide handle JSON. This also removes a latent raw 500: "enabled":1 made JsonReader.nextBoolean throw an IllegalStateException that escaped both the JsonParseException and IllegalArgumentException handlers. Structural checks run unconditionally even for a disabled section, because those shapes cannot be bound at all; the disabled exemption continues to cover value validation only.

  4. Both import overloads are asserted in testInvalidStickyNeverWritesOrPublishes, including the namespace overload with a mapped selector, and the test still verifies no rule write, no condition write and no event.

  5. Added StickySessionConfig.isUsable() as the single owner of the activation predicate; StickySessionAffinity.isActive delegates to it. One correction to the review text: the runtime predicate is a non-empty secret list with every entry non-blank (allMatch), not "at least one"; isUsable() keeps that. enabledValidationAgreesWithRuntimeActivation pins the agreement for every activation field, and the disabled case is asserted separately because it is deliberately exempt from value validation.

  6. Renamed validateDivideHandle to validateCanaryAndStickyConfig and removed the dead Objects.nonNull(divide) guard.

The docs now also narrow the runtime-tolerance claim: semantic values degrade to plain load balancing, while structural JSON type errors are rejected at parse time (as the canary section already did). Tests on this branch: common 682, divide 162, admin 2691 (the pre-existing flaky ShenyuClusterServiceTest.testSelectMasterErrorCountTracking failed once and passes 9/9 in isolation); checkstyle is clean in the three modules. On the previous code the new tests are red, including the null-cookie POJO case, the null key.type case, the activation-agreement test and the structural type-message test.

@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

FINAL cross-file review of PR #8305 (head 947c216) for sticky session config validation. Among 10 partitions, part-1 identified a HIGH severity regression: null check removed on DivideRuleHandle in RuleServiceImpl.java line 265, risking NPE. All other partitions passed. CI failed (passed=false). Therefore overall verdict is NEEDS_CHANGES.

Evidence

  • part-1 review: NEEDS_CHANGES with HIGH finding at shenyu-admin/.../RuleServiceImpl.java line 265 RIGHT side: removed null check on parseHandle result
  • CI status from attachment: complete=true, passed=false, check_count=67
  • FINAL synthesis: all_parts_passed=false due to part-1 NEEDS_CHANGES; complete_diff=true but CI not passed

Recommended next steps

  • Restore null check on 'divide' in RuleServiceImpl.java or ensure CanaryConfigValidator.parseHandle never returns null
  • Investigate and fix failing CI checks (67 checks, passed=false)
  • Re-review partition part-1 after fix to confirm resolution

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

Task: e8283956-5d10-494f-a0fb-c9e4c52e8705 · Commit: 947c216e0b010bb5afc5666a1357c9f8280343f7

Review verdict: NEEDS_CHANGES

FINAL scope synthesis of 10 partition reviews for PR #8305 at head 947c216. Partition part-1 reported NEEDS_CHANGES with a HIGH severity blocking finding (null check removed on DivideRuleHandle leading to potential NPE). All other partitions (part-0,2-9) returned PASS. CI passed=false. Per reduction rules, child NEEDS_CHANGES cannot be overridden and all_parts_passed is false; thus FINAL verdict is NEEDS_CHANGES. No local tests executed; no write tools used.

Publication: REQUEST_CHANGES (VERIFIED_BLOCKING_FINDINGS)

  • shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/RuleServiceImpl.java:265 [RIGHT] Null check on 'divide' returned by CanaryConfigValidator.parseHandle was removed. If parseHandle can return null, divide.getCanary() (and later divide.getSticky() at line 269) will throw NullPointerException, not caught as IllegalArgumentException, causing unhandled exception.

Review details

This PR adds management-plane validation for sticky sessions but introduces a potential NullPointerException in RuleServiceImpl.java. The null check on the DivideRuleHandle returned by CanaryConfigValidator.parseHandle was removed; if parseHandle can return null, divide.getCanary()/getSticky() will throw an unhandled NPE. Please restore the guard or guarantee non-null, and resolve the failing CI before merge.

@konlue

konlue commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Aias00 — the NPE path is not reachable, so I made the non-null contract explicit instead of restoring the caller guard.

parseHandle already rejects a non-object root (object(...) throws IllegalArgumentException), and DivideRuleHandle has a no-arg constructor, so Gson returns an instance for every valid root. Restoring Objects.nonNull(divide) in RuleServiceImpl would reintroduce the dead guard flagged in the previous review round. Instead, parseHandle now checks its own result and throws IllegalArgumentException("Invalid Divide handle JSON") if it is ever null, its javadoc states never null, and testParseHandleNeverReturnsNull pins the contract for {}, {"sticky":null} and "null" (the last one must throw because the root is not an object).

The failing CI check is the pre-existing flaky ShenyuClusterServiceTest.testSelectMasterErrorCountTracking (expected: <true> but was: <false>, 2724 run / 1 failure / 10 skipped), unrelated to this change; it passes 9/9 in isolation on this branch. The new commit reruns CI; if the same flake appears again I will rerun the failed job and report it as a known flaky rather than change unrelated cluster-master code in this PR.

@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

Cross-file synthesis of PR #8305 (head 1d600f3) for sticky session config validation. All 10 partition reviews passed with complete diff, but CI checks did not pass (passed=false, complete=true). Therefore final verdict cannot be PASS per policy.

Evidence

  • Frozen attachment: PR cross-file review JSON shows all_parts_passed=true, complete_diff=true, ci.passed=false
  • Child partitions part-0..part-9 all verdict PASS with no blocking findings
  • Head sha 1d600f3, base 585f99d

Recommended next steps

  • Investigate CI check failures (9 checks, passed=false) and ensure they pass
  • Confirm cross-replica verification tests align with runtime semantics
  • Re-run final review after CI green

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

Task: 564c9588-b082-4e75-8ec7-8406f3ef53fc · Commit: 1d600f3ed7d2ad5197934b40b666ddb283bca445

Review verdict: INCONCLUSIVE

FINAL synthesis scope: all 10 partition reviews returned PASS with complete_diff=true and no HIGH/CRITICAL findings. However CI status indicates passed=false (complete=true, check_count=9). Per review policy, a FINAL PASS requires all_parts_passed true, complete_diff true, and CI passed. Since CI did not pass, the verdict cannot be PASS; no concrete diff-anchored defects exist to force NEEDS_CHANGES, thus INCONCLUSIVE. Limitation: CI failure root cause not in supplied diff; cross-file runtime compatibility assumed from passing partition logic but not validated by green CI.

Publication: COMMENT (INCONCLUSIVE_REVIEW)

Review details

All supplied diff partitions review clean, but CI is failing. Please investigate CI failures before merging. No code-level blocking defects found in the reviewed partitions.

@konlue

konlue commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @Aias00 — the CI snapshot behind this verdict is stale; the same head is now fully green, and no code changed since it.

At 1d600f3ed (the exact head reviewed):

The earlier passed=false came from the previous head 947c216e0, whose pr_build hit the pre-existing flaky ShenyuClusterServiceTest.testSelectMasterErrorCountTracking (2724 run / 1 failure / 10 skipped; 9/9 in isolation). The 1d600f3ed rerun did not reproduce it. There are no code changes in this round — could you re-run the final review against the current checks?

If the stored review metadata still shows the old run, a manual re-trigger should pick up run 41971210351.

@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

FINAL review of PR #8305 at head 1d600f3: cross-file synthesis of 10 partition reviews all PASS, complete diff supplied, CI passed (68 checks). No HIGH/CRITICAL defects; management-plane sticky session validation and tests are coherent.

Evidence

  • Frozen attachment: all_parts_passed true, complete_diff true, global_complete_diff true
  • CI passed: 68 checks complete, passed true
  • 10 partition reviews (part-0..part-9) each verdict PASS with no blocking findings

Recommended next steps

  • Maintainers to perform final merge decision per project process
  • Optionally verify documentation matches released artifact

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

Task: 5ecdb40e-9a6e-447e-bc7f-e994015066b9 · Commit: 1d600f3ed7d2ad5197934b40b666ddb283bca445

Review verdict: PASS

SYNTHESIS/FINAL scope: assessed cross-file correctness from all child summaries. All 10 partitions reported PASS with empty blocking_findings. complete_diff and global_complete_diff true, CI passed (68 checks). No child NEEDS_CHANGES/INCONCLUSIVE overridden. Therefore FINAL PASS is justified. Limitation: no local test execution, but CI provides evidence.

Publication: APPROVE (EXPLICIT_PASS)

Review details

Recommendation: The PR is ready for maintainer review and merge given passing checks and complete review. No blocking issues found.

@Aias00
Aias00 merged commit 210f217 into apache:master Nov 10, 2026
68 checks passed
@Aias00 Aias00 added this to the 2.7.3 milestone Nov 13, 2026
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.

3 participants