Resync session tools when backend health changes - #6196
Conversation
Connected vMCP sessions snapshot their tool catalog at registration and are only resynced when a backend itself emits tools/list_changed. A backend flipping unhealthy to healthy (or the group's backend set changing) emits nothing, so live sessions kept serving the stale catalog until they reconnected, even though capability aggregation already health-filters the backend set. Give the health monitor a debounced OnChange callback fired when a backend's advertisability flips (the healthy/degraded boundary the aggregation filters by) or when UpdateBackends adds/removes backends. Serve subscribes and fans each delivery out to every live session's existing tools resync worker, which re-derives the advertised set under the session's captured identity, replaces the session tool store, and lets the SDK emit notifications/tools/list_changed to the client. Passthrough mode only: with the optimizer enabled the fan-out is a no-op, since rebuilding the find_tool/call_tool backing index is the follow-up half of stacklok#5786. Part of stacklok#5786 Signed-off-by: Prem Kumar Sompura <prem_sompura@hotmail.com>
cb9c4f5 to
ad69d8e
Compare
amirejaz
left a comment
There was a problem hiding this comment.
Really thorough PR — thanks for adapting the agreed design to the machinery that landed after the #5786 discussion (reusing the #5748/#5969 per-session resync workers rather than re-implementing the re-apply/notify steps is exactly right, and the "Special notes for reviewers" mapping made this easy to verify). Correctness and lifecycle hold up on my read: no synchronous re-entry into the monitor locks from notify() so Stop can't deadlock, eager deregistration on every server-observed termination path plus the lazy liveness-guard prune, and advertisable matching filterHealthyBackends' inclusion rule.
A couple of small, non-blocking items (inline). Nothing here needs to hold the PR up if you'd rather defer.
One more optional note: the tools resync worker is registered (healthResync.add) just before injectCoreSessionCapabilities runs, so a health flip in that narrow window could run SetSessionTools (REPLACE) concurrently with registration's setSessionToolsDirect (MERGE). Both derive from the live health-filtered core view and the SDK store is internally locked, so it self-heals on the next fan-out — and registering early is a deliberate "don't miss a change" choice — but a one-line comment acknowledging the overlap would help the next reader.
| // statuses are advertised; unhealthy, unknown, and unauthenticated are not. | ||
| // Keep the two in sync — a divergence would fire (or suppress) OnChange for | ||
| // transitions the catalog does not (or does) observe. | ||
| func advertisable(status vmcp.BackendHealthStatus) bool { |
There was a problem hiding this comment.
Non-blocking: this is now a second hand-kept copy of the healthy/degraded/"" inclusion rule that also lives in filterHealthyBackends (pkg/vmcp/core/core_vmcp.go). The "keep the two in sync" comment is good, but a silent divergence here would either fire OnChange for a transition the catalog doesn't observe or (worse) suppress one it does — with no test likely to catch it. Worth extracting a single shared predicate (e.g. exported from this package or on vmcp.BackendHealthStatus) that both call, so they can't drift. Cheap now, and the coupling is only going to get more load-bearing in PR2.
| // #5786 PR1 is passthrough-only: in optimizer mode the advertised | ||
| // meta-tools are unchanged by a health flip and rebuilding the per-session | ||
| // optimizer index is deferred to the optimizer-mode follow-up. | ||
| if s.optimizerFactory != nil { |
There was a problem hiding this comment.
Optional: since this is a no-op in optimizer mode, the healthResync.add at registration also serves no purpose there — those entries are only reclaimed via a terminate path or a backend-driven resync, so an optimizer-mode session that TTL-expires without ever emitting a backend list_changed lingers in the registry (holding its worker closure + captured identity/headers) until then. Bounded and low-severity, but skipping the add when optimizerFactory != nil would avoid it and read more clearly. If you're keeping it because PR2 will need the registration regardless, a one-line note to that effect would do.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6196 +/- ##
==========================================
- Coverage 72.51% 72.49% -0.03%
==========================================
Files 739 741 +2
Lines 76719 76822 +103
==========================================
+ Hits 55634 55692 +58
- Misses 17107 17164 +57
+ Partials 3978 3966 -12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Summary
A vMCP session's advertised tool catalog is derived once at registration, and the existing resync machinery (#5748) only re-derives it when a connected backend itself emits
notifications/tools/list_changed. A backend flipping unhealthy → healthy (or the group's backend set changing) emits nothing — the health monitor detected the transition but nothing consumed it — so already-connected sessions kept serving the stale catalog until they reconnected, even though capability aggregation already health-filters the backend set. This is PR 1 of the two-PR split agreed with @aponcedeleonch in #5786: passthrough mode only; the optimizer-mode catalog rebuild is the follow-up PR.pkg/vmcp/health: addMonitor.OnChange(fn ChangeListener)(also onhealth.Reporter), fired when a backend's advertisability flips — a transition across the healthy/degraded ⇄ unhealthy/unknown/unauthenticated partition, detected at thestatusTracker.RecordSuccess/RecordFailuretransition points, mirroringfilterHealthyBackends' inclusion rule — and whenUpdateBackendsadds/removes backends. Delivery is debounced to the monitor's check interval (leading edge immediate, in-window changes coalesced into one trailing delivery carrying a monotonic generation), so a flapping backend or multi-backend partition cannot storm listeners. Listeners run off the health-check path;Stopwaits for in-flight deliveries.pkg/vmcp/server:Servesubscribes via the core-owned monitor. The server keeps a registry of each live session's KindTools resync worker — the same per-session coalescing worker the backend-notification path builds, so identity/forwarded-header capture, the liveness guard, capability-cache invalidation, replace semantics, and the SDK's automatic downstreamnotifications/tools/list_changedemission are all shared — and triggers each on delivery. Sessions register on successful registration, deregister on server-observed termination paths, and are pruned lazily when a triggered resync finds them gone (TTL expiry / SDK-initiated DELETE end sessions without server involvement).find_tool/call_toolmeta-tools don't change on a health flip; rebuilding their backing index is PR 2).docs/arch/10-virtual-mcp-architecture.md: new "Health-driven tools resync" section.Part of #5786 (PR 1 of 2 — do not auto-close; PR 2 covers optimizer mode)
Type of change
Test plan
task test) — full suite green with race detection: debounce semantics (leading/trailing coalescing, stop cancels pending delivery, generations strictly increasing); statusTracker advertisability-transition matrix; monitor fires OnChange on fail→recover and on UpdateBackends add/remove, and stays quiet in steady state; server fan-out resyncs a live session (gains recovered tool, drops failed tool, invalidates capability cache), coalesces a 10-delivery burst into two re-derivations, no-ops in optimizer mode, and prunes dead sessionsvirtualmcp_health_list_changed_test.gospec run against a Kind cluster (kind-setup-e2e+operator-deploy-local): a Legacy session initialized while a backend is broken receivesnotifications/tools/list_changedon its standalone SSE stream when the backend recovers, then lists and successfully calls the recovered backend's tool on the same session without reconnectingtask lint-fix)Does this introduce a user-facing change?
Yes. Connected vMCP client sessions (passthrough mode) now receive
notifications/tools/list_changedand an updatedtools/listwhen a backend recovers/fails health checks or when the group's backend set changes, instead of serving the registration-time snapshot until reconnect.Implementation plan
Approved implementation plan (agreed in #5786, PR 1 scope)
pkg/vmcp/health/monitor.go: addtype ChangeListener func(gen uint64)andfunc (m *Monitor) OnChange(fn ChangeListener). Fire it from thestatusTrackertransition points (healthy⇄unhealthy) and fromUpdateBackends(add/remove), debounced so a flapping backend doesn't storm sessions. Callback onMonitorrather than a channel, per the thread.setSessionToolsDirectbypass as-is, per the thread's note on why that bypass exists.notifications/tools/list_changedto clients after applying, gated on the server advertisingtools.listChanged.call_toolnever resolves against a mismatched route.find_tool/call_toolcatalog rebuild.Special notes for reviewers
The agreed design predates #5748/#5969 landing, and several of its steps are now provided by machinery that didn't exist when the thread discussion happened — this PR wires the health monitor into that machinery rather than re-implementing those steps:
AggregateCapabilitiesand atomically swap the catalog + routing table, bump a generation" — the core is now stateless (P2.5 Move AS runner, status reporter, optimizer, health monitor under Serve #5443/P3.2 Reduce server.New body to the wrapper #5445: it health-filters and aggregates per call; "the core filters, Serve caches"), so there is no stored catalog to swap and no background re-aggregation to run. A background re-aggregation would in fact be wrong today: the capability cache and outbound backend auth are keyed on per-identity context (Consume backend notifications in vMCP and propagate list_changed #5748), which a background subscriber doesn't have — only the per-session workers carry the captured identity. The atomicity concern is inherently satisfied (each call derives one consistent aggregation+routing view), and the cache serves no stale merge (its key includes the backend-ID set, which a health flip changes; the shared resync path additionally invalidates it).setSessionToolsDirectpath" / "gate on advertisingtools.listChanged" — Consume backend notifications in vMCP and propagate list_changed #5748 already built the correct post-init apply path (resyncSessionTools: REPLACE semantics, so removals propagate, unlikesetSessionToolsDirect's registration-time merge) and already flippedWithToolCapabilities(true); the SDK emits the notification to each session whose tool store changes, so no explicitSendNotificationToAllClientscall is needed (it would duplicate).ChangeListenerpayload (monotonic, coalesced by debouncing) for correlation; the "skip an already-applied session" role is subsumed by the per-session workers' dirty-flag coalescing.CheckInterval(default 30s, same default as the status-reporting interval the thread referenced) — transitions are detected at check cadence, so this is the natural window, and it avoids threading the server-layer reporting interval into the core-owned monitor.SessionIdManagerwrapper (pruneOnTerminateSessionIDManager), since that path otherwise reaches the session manager without passing through server code. Only TTL expiry is pruned lazily (worker's liveness guard on the next fan-out); such an entry retains the worker closure (SDK session + captured identity/headers) until then, which the registry doc comment states explicitly.UpdateBackendsproperty changes (URL/transport) intentionally do not notify — membership-only, per the agreed scope; noted in the architecture doc.Generated with Claude Code