fix(distributed): make backend.stop report what it stopped - #11867
Open
localai-org-maint-bot wants to merge 1 commit into
Open
fix(distributed): make backend.stop report what it stopped#11867localai-org-maint-bot wants to merge 1 commit into
localai-org-maint-bot wants to merge 1 commit into
Conversation
backend.stop was the one lifecycle subject a worker never answered. The controller published and returned nil as soon as the local publish succeeded, so a stop that killed nothing, and a stop that failed outright, were indistinguishable from one that worked. The unload endpoint calls model.unload and then StopBackend. Only the first is acknowledged, so the endpoint answered 200 while the backend kept running and held its VRAM, and its own "backend stop failed" branch could never run. The worker logged the failure and nobody saw it. Give the subject a reply. The worker now enumerates the process keys it terminated and reports any per-process error, so StopBackend fails when the stop failed. Resolving to nothing stays a success: stopping a backend that is not running leaves the caller in the state it asked for, and eviction paths stop already-gone models routinely. The empty list is what says nothing matched, and ReportsStoppedProcesses is what makes that emptiness trustworthy, the same way BackendDeleteReply handles it. A worker built before this reply still receives the request and still stops the backend, it only stays silent, so a timeout degrades to the old assumption rather than failing every stop on a fleet mid-upgrade. Only silence degrades: a transport error is still reported, because UnloadRemoteModel skips its registry cleanup for a node it could not reach and needs to keep hearing about that. Assisted-by: Claude:claude-opus-5 golangci-lint
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Description
backend.stopwas the one lifecycle subject a worker never answered. Every other one — install, upgrade, delete, list, models-running, model-unload, model-stop — usesSubscribeReply; this one used a bareSubscribe, and the controller side was a barePublishthat returnednilas soon as the local publish succeeded.So a stop that killed nothing, and a stop that failed outright, were indistinguishable from one that worked.
POST /api/nodes/:id/models/unloadcallsmodel.unload(acknowledged) and thenStopBackend(not). I hit this on a live deployment: the endpoint returned{"message":"model unloaded"}/ HTTP 200 twice while the backend process kept running and held 27 GB of VRAM. The endpoint's own"model unloaded but backend stop failed"branch could never fire, and the worker'sxlog.Error("Failed to stop backend process")went to a log nobody was reading.This gives the subject a reply. The worker enumerates the process keys it terminated and reports any per-process error, so
StopBackendfails when the stop failed.Deliberately not errors:
backend.stop matched no running process. Stopping a backend that is not running leaves the caller in the state it asked for, and eviction and cleanup paths stop already-gone models routinely. Making it an error would break them. The empty list is what says nothing matched;ReportsStoppedProcessesis what makes that emptiness trustworthy, mirroring howBackendDeleteReplyalready solves the same problem.Only silence degrades. A transport error is still reported, because
UnloadRemoteModelskips its registry cleanup for a node it could not reach and needs to keep hearing about that. That distinction is what keeps the existing "continues when one node fails" contract intact.Notes for Reviewers
Compatibility runs both ways and neither direction breaks:
SubscribeReplydrops the response whenmsg.Reply == ""The one cost is that on a mixed fleet each stop against an un-upgraded worker waits out
backendStopAckTimeout(15s) before falling back. That bound is sized off the worker's own 5sworkerBackendFreeTimeoutplus the kill. It's noted in the docs.Verify:
Five specs in
unloader_test.goasserted the old fire-and-forget contract and now assert the new one; the rest of that suite (586 specs) was green throughout. New specs cover the reported failure, the matched-nothing success, the silent-worker degrade and the transport-error report.make lintreports 0 issues.One unrelated flake to flag honestly:
backend_logs_test.go:163(a WebSocket streaming test) failed once during this work. It passes 3/3 on this branch and passes on clean master; I don't touch websockets or backend logs.This came out of the same incident as #11860, which fixes the abandoned generations that made the node look wedged in the first place. The two are independent — this one is about being told the truth when you try to clean up afterwards.
Still outstanding from that incident, not addressed here:
sweepLeakedInFlightcannot fire while a replica keeps receiving traffic, becauseIncrementInFlightstampslast_usedat request start and the sweeper requireslast_usedto be 30 minutes stale. A leaked counter causes retries, and the retries keep refreshinglast_used, so the sweeper is disabled by exactly the condition it exists to correct. That one needs a design call on the discriminator rather than a patch.Signed commits