-
Notifications
You must be signed in to change notification settings - Fork 268
Skip known-bad backends when opening vMCP sessions #6162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
jerm-dro
wants to merge
3
commits into
main
Choose a base branch
from
jerm-dro/gate-session-init-on-backend-health
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+544
−8
Draft
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package health | ||
|
|
||
| import "github.com/stacklok/toolhive/pkg/vmcp" | ||
|
|
||
| // ShouldAdvertise reports whether a backend in this status may contribute | ||
| // capabilities to the advertised view (tools/list and friends). | ||
| // | ||
| // 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. | ||
| // | ||
| // Excluded: unhealthy (not responding), unknown (not yet probed), and | ||
| // unauthenticated (operator misconfiguration). | ||
| func ShouldAdvertise(status vmcp.BackendHealthStatus) bool { | ||
| return status == "" || | ||
| status == vmcp.BackendHealthy || | ||
| status == vmcp.BackendDegraded | ||
| } | ||
|
|
||
| // ShouldOpenSession reports whether a new session should attempt to open a | ||
| // connection to a backend in this status. | ||
| // | ||
| // This is deliberately STRICTER than ShouldAdvertise in one specific way: it | ||
| // excludes degraded. It is NOT a general "only healthy" predicate — it skips | ||
| // only statuses that positively establish the backend is a bad bet, and admits | ||
| // everything else, including not-yet-classified. | ||
| // | ||
| // The degraded asymmetry is the fix for #5861. Advertising a degraded backend's | ||
| // tools is cheap, but blocking `initialize` on it is not: session creation waits | ||
| // for every backend it attempts (session.makeBaseSession's wg.Wait), so a single | ||
| // slow backend sets the floor for the entire tenant's session-establishment | ||
| // latency. Worse, the handshake makes several sequential round trips, so the | ||
| // cost is a multiple of the backend's per-request latency, not one unit of it. | ||
| // | ||
| // A backend is marked degraded precisely because it is slow — which is exactly | ||
| // the property that must stay off the session-establishment critical path. Its | ||
| // tools remain advertised and callable (ShouldAdvertise still admits it); only | ||
| // the blocking per-session connect is skipped, and the health monitor keeps | ||
| // probing it on its own schedule so recovery is picked up normally. | ||
| // | ||
| // Unknown is admitted, unlike in ShouldAdvertise. The two filters answer | ||
| // different questions and must diverge here. Advertising a tool from a backend | ||
| // of unknown health risks surfacing a capability that cannot be served, so | ||
| // aggregation waits for confirmation. Session establishment has the opposite | ||
| // default: serving is not gated on the first health check completing (only the | ||
| // status reporter calls WaitForInitialHealthChecks), so sessions are routinely | ||
| // created while backends are still Unknown — during pod startup, and for a | ||
| // backend whose first check failed below the unhealthy threshold, which the | ||
| // monitor records as Unknown with a non-zero failure count | ||
| // (health/status.go RecordFailure). Skipping those would connect a session to | ||
| // zero backends during the startup window, which is both a regression against | ||
| // the pre-#5861 behaviour and a worse failure than the one being fixed. "Not yet | ||
| // known to be bad" must therefore fail open. | ||
| func ShouldOpenSession(status vmcp.BackendHealthStatus) bool { | ||
| // Skip only confirmed-bad statuses; everything else — including Unknown and | ||
| // the empty zero value — is attempted. | ||
| return status != vmcp.BackendDegraded && | ||
| status != vmcp.BackendUnhealthy && | ||
| status != vmcp.BackendUnauthenticated | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package health | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
|
|
||
| "github.com/stacklok/toolhive/pkg/vmcp" | ||
| ) | ||
|
|
||
| // TestShouldAdvertiseAndShouldOpenSession pins both health predicates against | ||
| // every BackendHealthStatus constant, plus the empty zero value and an | ||
| // unrecognized value. | ||
| // | ||
| // The two are asserted together because their relationship is the contract that | ||
| // matters, and it is not a simple ordering: ShouldOpenSession is stricter for | ||
| // degraded (advertising a slow backend's tools is cheap, blocking initialize on | ||
| // it is not — #5861) and looser for unknown (session establishment must not fail | ||
| // closed before the first health check completes). Testing them side by side | ||
| // makes an accidental change to either one visible as a change in the pairing. | ||
| // | ||
| // The unrecognized-value row pins a subtlety worth stating explicitly: the two | ||
| // predicates have opposite defaults for a status neither knows about. | ||
| // ShouldAdvertise is an allow-list, so an unrecognized status is NOT advertised | ||
| // (fails closed — a capability that may not be servable is withheld). | ||
| // ShouldOpenSession is a deny-list, so it IS attempted (fails open — better to | ||
| // connect to a backend of uncertain health than to strand a session with none). | ||
| // Each default is the conservative choice for its own question, but they point | ||
| // in opposite directions, so anyone adding a status must consider both. | ||
| func TestShouldAdvertiseAndShouldOpenSession(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| status vmcp.BackendHealthStatus | ||
| wantAdvertise bool | ||
| wantOpenSession bool | ||
| }{ | ||
| { | ||
| name: "empty means health monitoring disabled: assume usable", | ||
| status: "", | ||
| wantAdvertise: true, | ||
| wantOpenSession: true, | ||
| }, | ||
| { | ||
| name: "healthy", | ||
| status: vmcp.BackendHealthy, | ||
| wantAdvertise: true, | ||
| wantOpenSession: true, | ||
| }, | ||
| { | ||
| // The asymmetry that makes #5861's fix work: still advertised, but | ||
| // never blocked on during session establishment. | ||
| name: "degraded is advertisable but not worth blocking initialize on", | ||
| status: vmcp.BackendDegraded, | ||
| wantAdvertise: true, | ||
| wantOpenSession: false, | ||
| }, | ||
| { | ||
| name: "unhealthy", | ||
| status: vmcp.BackendUnhealthy, | ||
| wantAdvertise: false, | ||
| wantOpenSession: false, | ||
| }, | ||
| { | ||
| // The other asymmetry: aggregation waits for confirmation, session | ||
| // establishment must not, or a cold monitor connects sessions to zero | ||
| // backends during pod startup. | ||
| name: "unknown is not advertised but is still attempted", | ||
| status: vmcp.BackendUnknown, | ||
| wantAdvertise: false, | ||
| wantOpenSession: true, | ||
| }, | ||
| { | ||
| name: "unauthenticated (operator misconfiguration)", | ||
| status: vmcp.BackendUnauthenticated, | ||
| wantAdvertise: false, | ||
| wantOpenSession: false, | ||
| }, | ||
| { | ||
| // ShouldAdvertise is an allow-list (fails closed); ShouldOpenSession is | ||
| // a deny-list (fails open). Opposite defaults, deliberately — see the | ||
| // doc comment above. | ||
| name: "unrecognized status: not advertised, but still attempted", | ||
| status: vmcp.BackendHealthStatus("some-future-status"), | ||
| wantAdvertise: false, | ||
| wantOpenSession: true, | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| assert.Equal(t, tt.wantAdvertise, ShouldAdvertise(tt.status), | ||
| "ShouldAdvertise(%q)", tt.status) | ||
| assert.Equal(t, tt.wantOpenSession, ShouldOpenSession(tt.status), | ||
| "ShouldOpenSession(%q)", tt.status) | ||
| }) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.