Skip to content

[ISSUE #8300] Add the opt-in adaptive scoring strategy for divide - #8365

Merged
Aias00 merged 1 commit into
apache:masterfrom
konlue:feat/8300-adaptive-scoring
Nov 10, 2026
Merged

Aias00 merged 1 commit into
apache:masterfrom
konlue:feat/8300-adaptive-scoring

Conversation

@konlue

@konlue konlue commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Part of #8300.

Problem

The divide plugin ranks its candidates with per-strategy heuristics that only see part of the picture: p2c pairs the smoothed lag with the in-flight count, but its lag attribution reads a shared per-upstream timestamp, so overlapping attempts attribute each other's elapsed time, and nothing in the selection path distinguishes a fast 5xx from a slow success — a node that fails within a millisecond looks cheaper than one that answers correctly in fifty. This adds the opt-in adaptive load-balance strategy as the first delivery of the adaptive scoring evaluation: selection cost stays O(1), cold start and discovery churn behave exactly like today, and no throughput improvement is claimed without the versioned benchmarks, which remain a follow-up.

Approach

The new AdaptiveLoadBalancer keeps the p2c pairing — two random candidates compared without a full-list sort — and compares per-upstream adaptive scores instead of the raw (sqrt(lag)+1) * inflight product: (smoothedLatency + 1) * (inflight + 1) / max(warmUpWeight, 1), smaller is better. While a candidate is still cold (fewer than five latency samples) the pair is compared with the p2c load formula for both sides, so the two scales are never mixed inside one comparison, and restarts, discovery churn and brand-new selectors behave exactly like the current p2c. Idle nodes keep the p2c forced exploration, so a degraded node is probed again and can recover.

The feedback is a single hook, not a second telemetry pipeline: the divide plugin's existing per-strategy completion trigger gains an adaptive branch that measures each attempt with its own start time — overlapping attempts never share a timestamp, which removes the shared-stamp attribution the p2c path carries — classifies the outcome, and hands it to the selector. The classification keeps upstream failures distinct from client-caused results: a downstream cancellation says nothing about the upstream, a 4xx is neutral (no latency recorded, no penalty), and a 5xx or a transport failure enters the smoothing as a synthetic latency sample of at least one second. That floor is what keeps a fast failing node unattractive: finishing quickly can never make its score smaller than the failure cost, and a clean node recovers because subsequent successful samples dominate the smoothing.

Statistics are keyed by the existing UpstreamIdentity, so they survive equivalent discovery refreshes, live in a bounded map (amortized prune of identities without feedback for ten minutes, only once it outgrows 256 entries), and are replaced atomically per feedback. Health checks, canary partitioning, breaker admission, sticky affinity, deterministic keys and the draining/ready gating all run before this selector sees the candidates, so the adaptive comparison only ranks already eligible targets; sticky and deterministic hits reserve their in-flight attempt with the same compensation the p2c path uses. Activation is the smallest possible surface: the new adaptive entry of the load-balance enum, selectable per divide rule like every other strategy; benchmarking, a shadow mode and the alignment with the observation contracts of #8209/#8158 stay follow-ups, and the feedback hook is deliberately the single seam those contracts will plug into.

Testing

All tests are deterministic and sleep-free. They cover the cold-start fallback comparison, warm candidates preferring the lower smoothed latency, in-flight work tipping the balance between equal latencies, a millisecond 5xx being smoothed in with the failure floor and losing to a slower healthy node, 4xx observations being neutral (no latency movement, no sample count), a transport failure entering with the floor without a real measurement, successful samples pulling the smoothing back below the floor, the forced exploration of an idle node, the singleton reservation pairing, the SPI resolution, the static feedback routing to the registered selector, and the bounded prune.

shenyu-loadbalancer 107 tests (95 existing + 12 new), shenyu-plugin-divide 159 tests (all existing), Checkstyle clean on shenyu-common, shenyu-loadbalancer and shenyu-plugin-divide.

@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 #8365 adds an opt-in adaptive load-balance strategy for the divide plugin. Frozen evidence claims complete_diff=true, 64 green CI checks, +779/-5 across 7 files. Direct head-version reads in this session covered AdaptiveLoadBalancer (full), AbstractLoadBalancer.select (full), and partial AdaptiveUpstreamStats/Test, but the VERSIONED_DIFF pages were archived (not surfaced) and the modified integration files (DividePlugin, LoadBalanceEnum, SPI) were not directly read.

Evidence

  • AdaptiveLoadBalancer.java (head ee6e275) read in full: p2c two-candidate pairing retained; compare() gates adaptive vs cold-start p2c scale via bothAdaptive; select() reserves in-flight on the singleton short-circuit; feedback() resolves the @Join singleton so selection and feedback share state.
  • AbstractLoadBalancer.select (head ee6e275, lines 40-73) confirms the size==1 short-circuit that the adaptive in-flight accounting depends on.
  • AdaptiveUpstreamStats constants (MIN_SAMPLES=5, EWMA_WINDOW_MILLIS=600, FAILURE_PENALTY_MILLIS=1000, PRUNE_THRESHOLD=256) and bothAdaptive/pruneIfNeeded verified; record()/score() bodies (lines ~79-118, 157-208) were not surfaced.

Recommended next steps

  • Re-run resource_diff for base 5bd9305b / head 09a290d5 across all offsets (0..40272) and read every page to certify full line-level coverage of the +779/-5 diff.
  • Read the modified DividePlugin.java (+50/-4) and LoadBalanceEnum.java (+8/-1) head versions to verify the feedback-hook contract and enum wiring.
  • Read AdaptiveUpstreamStats record()/score() (lines ~79-208) to validate the failure-floor EWMA smoothing math.

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

Task: 28d372d3-d0a7-4561-985f-19897a2a1028 · Commit: ee6e2758b425284a0ef6e3a232c0120c21e6bf84

Review verdict: INCONCLUSIVE

Full diff coverage cannot be certified: all VERSIONED_DIFF pages (base 5bd9305b / head 09a290d5) returned as archived ledger observations and were not surfaced in this session, and the modified integration files DividePlugin.java (+50/-4), LoadBalanceEnum.java (+8/-1), the SPI registration file, and AdaptiveUpstreamStats record()/score() were not directly read. Per review policy, incomplete coverage must be reported as INCONCLUSIVE rather than turned into approval; no concrete HIGH/CRITICAL defect was identified in the code that was read.

Publication: COMMENT (REVIEW_EVIDENCE_MISSING)

Review details

This review cannot be finalized as PASS. The complete captured diff was not surfaced in this session (all resource_diff pages returned as archived ledger observations), and several changed files — DividePlugin.java (+50/-4), LoadBalanceEnum.java (+8/-1), the SPI registration, and AdaptiveUpstreamStats record()/score() — were not directly read. Per review policy, incomplete coverage must yield INCONCLUSIVE rather than approval. The new AdaptiveLoadBalancer code that was read looks coherent (p2c pairing preserved, adaptive/cold-start scale mixing avoided, in-flight reservation matches the divide completion decrement, feedback resolves the registered @Join singleton), but full verification of the integration contract is outstanding. Recommend re-running the versioned diff across all offsets and reading the modified files before merging.

@847850277

847850277 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Meeting review (request_changes) — strengthened

Three-agent meeting consensus on #8365 (linked #8300), head ee6e275: request_changes. Independent SPI / opt-in adaptive scoring direction is worth keeping, but several issues change real routing and should block merge until fixed with regression tests.

Must fix

M1 (P1) — Bind feedback & inflight to the actual HTTP attempt
Today feedback attaches to the enclosing logical request: DividePlugin captures the initial upstream A, then reads the whole request’s final status and total elapsed time. On failover (A fails → B succeeds), success/latency can be credited to A while B’s adaptive inflight is reserved and never released (DefaultRetryStrategy has no adaptive release branch). Also, ON_ERROR currently treats local/client failures the same as upstream transport failure (e.g. local chunked 413 and downstream write errors), which incorrectly punishes the upstream.

  • DividePlugin.java (~343-347, 363-384, 515)
  • DefaultRetryStrategy.java (~86, 172-197)

Acceptance: each real HTTP attempt gets its own monotonic start, target, headers, and terminal fact; reserve/release exactly once through body complete/error/cancel; distinguish local/client vs downstream delivery vs upstream failure. Overlap tests must assert per-attempt elapsed/target/terminal independence (same-tick equal beginTimes are fine).

M2 (P1) — Probe / specify-domain reserve & real identity
These two paths fail differently and should not be conflated:

  • Half-open probe: only P2C gets +1, but adaptive completion unconditionally -1, so repeated probes can drive adaptive inflight negative and make a bad node look cheaper.
  • Specify-domain override: builds a temporary Upstream copy without a paired reserve and drops group/version, so feedback can land on the wrong identity (usually 1→0 on the copy, not a persistent negative counter).

Unify attempt ownership for every bypass: pair reserve/release on success/error/cancel and keep the real target identity for scoring. A non-negative score clamp is defense-in-depth only — it does not replace pairing.

  • DividePlugin.java (~204-222, 365)

M3 (P1) — Same-tick EWMA drop & 4xx time base
Same-millisecond samples get weight 0, so failures after a same-tick success can be ignored while sample count still rises. Neutral 4xx still updates lastFeedbackMillis, distorting the next effective sample’s smooth weight (e.g. 50ms@t0 → fail@t600 → 650, but inserting 4xx@t599 yields ~51). Fix the sample time base / same-tick aggregation so failures are not dropped and 4xx does not move the next sample’s clock base. Treat 1000ms as a failure-input floor, not a final score floor.

  • AdaptiveUpstreamStats.java (~181-193)

M4 (P2) — Bounded, scoped stats lifecycle
After >256 active identities, every multi-candidate select can full-table removeIf; singleton selection bypasses doSelect so prune may never run; score does not check sample age; identity is only protocol/url/group/version (no selector/workload), and remove/re-add is not hooked to cleanup.

  • AdaptiveLoadBalancer.java (~98-100)
  • AdaptiveUpstreamStats.java (~76, 107-110, 140-145)

Required: budgeted/throttled cleanup covering singleton/churn; define stale fallback; make lifecycle contracts explicit (isolation granularity / cleanup mechanism can be author-equivalent designs if validated).

M5 (P2) — One snapshot for both candidates; cold fallback lag gap
Warmth is checked for both ends, then scores are read separately; concurrent prune can leave one candidate on adaptive while the other falls back to p2c — mixed scales in one comparison. Cold fallback to p2c is intentional design, but that path reads Upstream.lag, which the adaptive branch does not maintain — do not claim parity with existing p2c without verifying that lag surface.

  • AdaptiveLoadBalancer.java (~133-144)

M6 (P2) — Limit adaptive to wired Divide HTTP workloads
WebSocket (and similar) can call adaptive and bump inflight, but only leastActive gets session accounting — no adaptive feedback/release. Unsupported paths must be refused or safely bypassed/fallbacked at enable/execution time with tests; correctly wiring and verifying a path also satisfies this. Docs alone are not enough.

  • e.g. WebSocketPlugin.java (~100-119)

Regression tests for M1–M6 are merge acceptance criteria (mirror existing p2c skeletons where possible), not optional follow-ups.

Suggestions (non-blocking)

  • Higher-precision score comparison under real weights / warmup.
  • Confirm Admin LOAD_BALANCE dictionary / seed SQL paths if the UI needs them.
  • Add diagnostics / versioned benchmarks; do not claim perf wins without data.

Bottom line

Keep the SPI and algorithm direction, but block merge until attempt ownership, probe pairing, and EWMA time-base issues are fixed and covered by tests. P2 items above are still merge-blocking for correctness/lifecycle, with room for equivalent implementations.

@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 #8365 adds an opt-in adaptive load-balance strategy for the divide plugin. It introduces AdaptiveLoadBalancer (p2c-style two-random pick + per-upstream EWMA latency/inflight/weight scoring), AdaptiveUpstreamStats (bounded per-identity feedback state, failure floor, prune), AdaptiveOutcome (SUCCESS/CLIENT_ERROR/SERVER_ERROR/TRANSPORT_ERROR), registers it via SPI and LoadBalanceEnum (code 7), and wires the completion trigger in DividePlugin. The change is self-contained, fully covered by 263 lines of tests, and CI passed all 64 checks. No blocking defect was found.

Evidence

  • Full VERSIONED_DIFF read (40,272 chars, all pages has_more=false): 7 files — LoadBalanceEnum (ADAPTIVE code 7), AdaptiveLoadBalancer, AdaptiveOutcome, AdaptiveUpstreamStats, SPI registration, AdaptiveLoadBalancerTest (263 lines), DividePlugin integration.
  • DividePlugin.adaptiveFeedback correctly pairs the reservation: upstream.getInflight().decrementAndGet() runs before the CANCEL early-return, so the in-flight count is released on every completion signal (SUCCESS/ERROR/CANCEL); sticky-binding and hash-pick branches also increment for ADAPTIVE, mirroring the p2c #7974 accounting fix.
  • AbstractLoadBalancer.select short-circuits size==1 before doSelect, so AdaptiveLoadBalancer.doSelect/pickTwoUpstreams only ever see size>=2 (no divide-by-zero/NPE); the singleton reservation is made in AdaptiveLoadBalancer.select for size==1, matching the always-decrement completion path.

Recommended next steps

  • Consider adding an explicit test asserting adaptiveFeedback releases the in-flight reservation on the CANCEL SignalType (the decrement-before-return is already correct, but the new suite does not appear to cover the CANCEL path).
  • Optionally document the new adaptive load-balance option for the divide plugin in the user docs.

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

Task: 6d8fa001-19e7-4650-a885-3d86178692be · Commit: ee6e2758b425284a0ef6e3a232c0120c21e6bf84

Review verdict: PASS

Complete frozen diff reviewed end-to-end; cross-file contract (enum -> SPI -> plugin constant) is consistent; in-flight accounting is correctly paired on all selection and completion paths; cold-start always falls back to the raw p2c load formula (scales never mixed); 4xx neutral, 5xx/transport floored at 1000ms; bounded prune at 256 entries; 263 lines of new tests cover scoring, cold-start, prune, forced exploration and singleton pairing; CI 64/64 success, passed=true. No blocking defect introduced by this PR.

Publication: APPROVE (EXPLICIT_PASS)

Review details

Reviewed #8365 (ee6e275). Verdict: PASS. The full frozen diff was read end-to-end (all pages delivered, has_more=false) and every integration seam was verified: the ADAPTIVE enum, SPI registration, and DividePlugin constant line up; in-flight accounting is correctly paired on all selection paths (doSelect, singleton short-circuit, sticky-binding hit, hash pick) and always released in adaptiveFeedback via decrement-before-CANCEL-return; AbstractLoadBalancer.select's size==1 guard prevents pickTwoUpstreams from ever seeing size<2; cold-start always falls back to the raw p2c load formula (scales are never mixed); 4xx is neutral while 5xx/transport is floored at 1000ms; the feedback map is bounded (prune at 256, idle 10m). 263 lines of new tests cover scoring, cold-start, prune, forced exploration and singleton pairing, and CI is green (64/64). Recommend merging.

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