Skip to content

Skip known-bad backends when opening vMCP sessions - #6162

Draft
jerm-dro wants to merge 3 commits into
mainfrom
jerm-dro/gate-session-init-on-backend-health
Draft

Skip known-bad backends when opening vMCP sessions#6162
jerm-dro wants to merge 3 commits into
mainfrom
jerm-dro/gate-session-init-on-backend-health

Conversation

@jerm-dro

Copy link
Copy Markdown
Contributor

Summary

A backend that is merely slow — not down, not erroring — could drag an entire VirtualMCPServer tenant's client-facing initialize success rate to roughly a coin flip, with no self-healing. The reporter saw this from one backend whose real round-trip latency hovered in the 10-25s range while the tenant's other 18 backends aggregated in ~1s.

Root cause: health status gated capability aggregation but never connection establishment, and establishment runs first. Manager.listAllBackends returned the registry unfiltered, and session.makeBaseSession blocks on wg.Wait() for every backend it attempts — so one slow backend set the floor for the whole tenant's session latency. The circuit breaker's CanAttempt() had exactly one production caller: the monitor's own background loop. Every new session re-attempted a backend the monitor already knew was bad and re-paid close to the full timeout.

Worse than the issue describes: the handshake makes several sequential round trips, each paying full latency. The regression test measures 8s of session-creation delay from a 2s backend — 4× amplification. So a 10-25s backend costs ~40-100s, which is why the reporter saw hard timeouts rather than merely slow sessions.

What changed:

  • health.ShouldOpenSession / health.ShouldAdvertise — the health predicate, now shared instead of copied. core.filterHealthyBackends calls ShouldAdvertise (behaviour-preserving).
  • sessionmanager consults the health monitor and skips backends already known bad before opening connections.
  • server.New wires coreVMCP.BackendHealth() into the session manager config.

Fixes #5861

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

task test passes in full. task lint-fix reports 4 issues, all pre-existing on a clean main (verified via git stash -u) and none in files this PR touches.

The four new regression tests were written before the fix and confirmed to reproduce the failure:

--- FAIL: TestRegression_CreateSession_SkipsKnownUnhealthySlowBackend (8.01s)
        "8.009782083s" is not less than "1s"
--- FAIL: TestRegression_CreateSession_DegradedSlowBackendDoesNotBlockTenant (8.01s)
--- PASS: TestRegression_CreateSession_HealthyBackendStillConnected
--- PASS: TestRegression_CreateSession_NilHealthProviderConnectsAll

After the fix all four pass (8.01s → 0.00s). The two guard tests pass in both runs by design — they prove the fix is not simply skipping every backend, which would make the latency assertions pass vacuously.

Changes

File Change
pkg/vmcp/health/policy.go New. ShouldAdvertise / ShouldOpenSession — the shared health predicate and the rationale for their asymmetry.
pkg/vmcp/core/core_vmcp.go filterHealthyBackends delegates to health.ShouldAdvertise. No behaviour change.
pkg/vmcp/server/sessionmanager/factory.go FactoryConfig.BackendHealth (optional; nil = health gating disabled).
pkg/vmcp/server/sessionmanager/session_manager.go listAllBackends gates via new shouldOpenSession.
pkg/vmcp/server/server.go Wires coreVMCP.BackendHealth() into sessMgrCfg.
pkg/vmcp/server/sessionmanager/slow_backend_regression_test.go New. Four regression tests reproducing #5861.

Does this introduce a user-facing change?

Yes. A single slow backend no longer inflates initialize latency for an entire tenant. Once the health monitor marks a backend unhealthy or degraded, new sessions stop blocking on it; its tools stay advertised and callable, and the monitor keeps probing it, so recovery is unaffected. Deployments with health monitoring disabled are unchanged.

Special notes for reviewers

The load-bearing decision is the asymmetry. ShouldOpenSession excludes degraded; ShouldAdvertise includes it. Without that, this PR would not close #5861: the reported backend oscillated unhealthy → degraded → unhealthy continuously (its 10-25s latency straddles the compiled-in 5s degraded threshold and 10s probe timeout, with no hysteresis), so reusing the aggregation predicate verbatim would leave every degraded-phase session paying full latency. TestRegression_CreateSession_DegradedSlowBackendDoesNotBlockTenant pins this. Worth confirming you agree "degraded = advertisable but not worth blocking initialize on" is the right policy.

Please scrutinise the wiring — it is in core request-path code. Three things I verified rather than assumed:

  • No import cycle: pkg/vmcp/health depends only on vmcp, vmcp/auth/types, and health/context.
  • ratelimit and codemode both embed core.VMCP, so BackendHealth() promotes to the real core through the decorator chain.
  • BackendHealth() returns a true nil interface when monitoring is disabled (core_vmcp.go:260), avoiding the typed-nil-in-interface trap that serve.go:450 documents having previously caused a /readyz panic.

Known limitation: the latency assertions are wall-clock. The 4× amplification gives an 8:1 margin against the 1s budget, which should hold, but flag it if you would rather not have timing assertions in CI.

Not fixed here, deliberately (each its own PR):

  1. operational.timeouts is inert — validated (config/validator.go:416), defaulted, in the CRD, documented, but never read by production code. The reporter considered raising timeouts.perWorkload as a workaround; it would have done nothing. session.WithBackendInitTimeout is the ready seam and has no production caller.
  2. partialFailureMode is inert — same pattern (validator.go:466). docs/operator/virtualmcpserver-kubernetes-guide.md:562 recommends best_effort for exactly this failure mode. Aggregation is hardcoded best-effort at default_aggregator.go:173 regardless of the setting. Either implement or remove.
  3. No hysteresis on the 5s/10s health thresholds (health/monitor.go:216) — the backend still flaps, it just no longer drags the tenant down while doing so. Design question worth its own issue.

Prior art: #3530 attempted the routing half of this and was closed unmerged for PR size (>1000 lines), with a review objection to adding complexity to defaultAggregator. This PR is ~90 lines of non-test change and touches no aggregator logic beyond delegating an existing predicate.

Generated with Claude Code

A backend that is merely slow (not down) could drag an entire
VirtualMCPServer tenant's client-facing initialize success rate
to roughly a coin flip, with no self-healing.

Health status gated capability aggregation but never connection
establishment, and establishment runs first: listAllBackends
returned the registry unfiltered and makeBaseSession blocks on
every backend it attempts. One slow backend therefore set the
floor for the whole tenant's session latency. Because the
handshake makes several sequential round trips, the cost is a
multiple of the backend's per-request latency -- a 2s backend
delays session creation by 8s -- so a 10-25s backend loses the
race against a typical ~30s client timeout.

Session establishment now consults the health monitor and skips
backends already known bad. The predicate is deliberately
stricter than the advertising one: it excludes degraded, because
degraded means slow, which is precisely what must stay off the
critical path. A degraded backend's tools remain advertised and
callable, and the monitor keeps probing it, so recovery is
unaffected. Without this asymmetry the fix would not hold: the
reported backend oscillated unhealthy <-> degraded continuously,
so reusing the aggregation predicate would leave every
degraded-phase session paying full latency.

Health monitoring being disabled preserves prior behaviour --
every backend is attempted.
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Jul 31, 2026
@jerm-dro
jerm-dro requested a review from Copilot July 31, 2026 17:16
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.59%. Comparing base (b7e824c) to head (e8f50bd).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6162      +/-   ##
==========================================
+ Coverage   72.56%   72.59%   +0.02%     
==========================================
  Files         736      737       +1     
  Lines       76331    76382      +51     
==========================================
+ Hits        55391    55446      +55     
+ Misses      17019    17013       -6     
- Partials     3921     3923       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI 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.

Pull request overview

This PR addresses vMCP session-creation latency amplification by skipping backends that the health monitor has already classified as not worth blocking initialize on, instead of re-attempting every backend connection during each new session. It also centralizes the “advertise vs. connect” health policy into shared predicates to avoid duplicated, drifting logic.

Changes:

  • Introduces shared health predicates (health.ShouldAdvertise / health.ShouldOpenSession) and reuses them from core capability aggregation.
  • Wires an (optional) health status provider into the session manager and filters backends before session establishment.
  • Adds regression tests reproducing #5861 with slow-but-working backends.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pkg/vmcp/health/policy.go Adds shared health predicates that encode “advertise vs connect” asymmetry.
pkg/vmcp/core/core_vmcp.go Reuses the shared advertise predicate for capability aggregation filtering.
pkg/vmcp/server/sessionmanager/factory.go Extends session manager factory config to accept an optional backend health provider.
pkg/vmcp/server/sessionmanager/session_manager.go Filters backends during session establishment based on health monitor status.
pkg/vmcp/server/server.go Wires core’s backend health provider into the session manager config.
pkg/vmcp/server/sessionmanager/slow_backend_regression_test.go Adds regression tests for slow backend behavior in session creation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/vmcp/server/sessionmanager/slow_backend_regression_test.go
Comment thread pkg/vmcp/server/sessionmanager/session_manager.go
Address review feedback on the session-establishment health gate.

The gate skipped any status that was not healthy, which included
Unknown and so failed closed before the monitor had confirmed
anything. Two paths reach it: a backend the monitor does not track
yet (a Pending k8s workload maps to Unknown), and one whose first
check failed below the unhealthy threshold, which is recorded as
Unknown with a non-zero failure count. Serving is not gated on the
first health check, so sessions are created in that window and
would have connected to zero backends -- a regression against the
prior behaviour and worse than the bug being fixed. The gate now
skips only confirmed-bad statuses.

The regression tests asserted the skip via wall-clock latency
alone, which is vulnerable to CI noise. The slow backend now
counts the requests it receives, so the skip is asserted directly;
the latency bound is kept as a secondary signal of what the user
experiences. Verified both assertions still fail without the fix
(4 requests, 8s) -- the counter also shows the handshake makes
four sequential round trips, which is why a 2s backend costs 8s.

The two latency tests differed only by status, so they are now one
table-driven test.
@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 31, 2026
@jerm-dro
jerm-dro requested a review from Copilot July 31, 2026 17:53

Copilot AI 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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

pkg/vmcp/server/sessionmanager/session_manager.go:847

  • listAllBackends is used for both CreateSession and RestoreSession (via loadSession), but the doc comment currently says "a new session". Tightening the wording to "session establishment" avoids misleading readers about the restore path also being filtered.
// listAllBackends returns the backends a new session should attempt to connect
// to, skipping any the health monitor has already classified as not worth
// blocking on (see shouldOpenSession).

pkg/vmcp/health/policy.go:14

  • The comment implies an empty health status means monitoring is disabled, but the code is really treating the empty/zero value as "assume healthy" for backward compatibility (which can also cover an unset registry status). Clarifying this avoids readers inferring stronger semantics than the predicate enforces.
// Degraded backends are included: they are slow but working, and hiding their
// tools would be a worse outcome for the caller than serving them. An empty
// status means health monitoring is disabled, which is treated as healthy so a
// deployment without a monitor behaves as it did before monitoring existed.

Self-review found the new predicates were only exercised
indirectly, via the core aggregation tests and the sessionmanager
integration tests. Nothing asserted them directly, and nothing
covered a status value that is none of the declared constants.

Assert both together, since their relationship is the contract
that matters and it is not a simple ordering: ShouldOpenSession is
stricter for degraded and looser for unknown. Testing them side by
side makes an accidental change to either visible as a change in
the pairing.

The unrecognized-status row documents that the two have opposite
defaults -- ShouldAdvertise is an allow-list and fails closed,
ShouldOpenSession is a deny-list and fails open. Each is the
conservative choice for its own question, but they point in
opposite directions, so a status added later must be considered
against both.
@jerm-dro

Copy link
Copy Markdown
Contributor Author

Self-review findings

Ran two independent adversarial reviews against this branch (fresh context, no shared framing) to try to break it. Both cleared the two highest-risk areas; one found a real residual gap I'm disclosing below. One test gap fixed in edf59da.

Cleared — the two things most likely to have made this worse

Session restore is gated too, not just session creation. My highest-suspicion area was RestoreSession (the Redis-backed cross-pod recovery path after a pod restart or cache eviction) reopening the bug. It doesn't: factory.RestoreSession has exactly one call site, session_manager.go:760, and it already passes the same health-gated sm.listAllBackends(restoreCtx) that CreateSession uses at :343. Those are the only two MakeSession*/RestoreSession call sites in the manager.

No advertised-but-uncallable trap. ShouldAdvertise admits degraded while ShouldOpenSession skips it, so I needed to confirm a degraded backend's tools stay callable in a session that never connected to it — otherwise this fix would trade one bug for a worse one. They do: core.CallTool routes through c.backendClient.CallTool (core_calls.go:82), and legacyCallTool/modernCallTool open a fresh per-call client (client.go:1681). The session's connections map is never consulted on the Serve path. A degraded backend's tool call pays that backend's own latency, exactly as before — unchanged behaviour.

Also confirmed: the filterHealthyBackends refactor is behaviour-identical across all six status values; BackendHealth() promotes correctly through both decorators (neither overrides it); the typed-nil-in-interface hazard is genuinely avoided.

Fixed: edf59da5c

The two new predicates were only exercised indirectly. Added pkg/vmcp/health/policy_test.go asserting both against every status constant plus empty and an unrecognized value. Writing it surfaced something worth stating explicitly: the two predicates have opposite defaults for an unknown status. ShouldAdvertise is an allow-list and fails closed; ShouldOpenSession is a deny-list and fails open. Each is conservative for its own question, but they point in opposite directions, so anyone adding a status must consider both. Now pinned and documented.

⚠️ Residual gap: the cold-start window is NOT closed

This narrows #5861 rather than fully eliminating it, and reviewers should weigh whether that's acceptable for merge.

Nothing orders the first health check before serving begins. WaitForInitialHealthChecks has exactly one production caller — the status-reporting goroutine (server/status_reporting.go:64) — which gates reporting, not session creation or the HTTP listener. So on a fresh pod the slow backend's status is Unknown, which ShouldOpenSession deliberately admits (it must, or sessions connect to zero backends during startup), and those sessions still block on it.

Window length depends on how the first probe resolves:

  • Succeeds-but-slow (5-10s, matching the reporter's own duration=7.86s log): resolves to degraded on the first check — window ≈ one probe.
  • Times out (real latency 10-25s vs. the 10s default probe timeout): RecordFailure's not-exists branch leaves status Unknown until consecutiveFailures >= UnhealthyThreshold (default 3) at CheckInterval 30s — up to ~60-90s.

It is not bounded in session count: every initialize landing in that window pays full cost.

This directly undercuts one of the reporter's observations — "a freshly-restarted vmcp pod showed the identical failure on its very first attempt." The steady-state failure (a confirmed-bad backend permanently dragging the tenant) is fixed; the restart-triggered instance is only narrowed. Since they observed the bug across two restart cycles, they may still see it briefly after a restart.

I'm proposing to keep Fixes #5861 because the primary, persistent failure mode is closed and the remaining window is bounded per restart rather than indefinite — but that's a reviewer call, and I'll change it to Refs #5861 plus a follow-up issue if you'd rather not auto-close.

Closing the window properly needs one of: pre-warming health status before the listener accepts traffic, or a first-probe-in-flight state distinct from Unknown. Either is a design change beyond this PR's scope, and gating serving on initial health checks has its own cost (it delays readiness by up to the probe timeout × threshold).

Also confirmed out of scope

partialFailureMode and operational.timeouts remain inert; no hysteresis on the 5s/10s thresholds so the flapping itself continues; the capability cache is still undocumented. All three were already listed in the PR description as deliberate exclusions.

@github-actions github-actions Bot added size/M Medium PR: 300-599 lines changed and removed size/M Medium PR: 300-599 lines changed labels Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Medium PR: 300-599 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

vmcp: a backend with elevated-but-not-down latency drops its tenant's client-facing success rate to ~50%, with no self-healing

2 participants