Skip to content

Commit 2b7a049

Browse files
committed
fix(distributed): keep remote unload idempotent, ask presence separately
2035a4d made UnloadRemoteModel return ErrRemoteModelNotLoaded when no node holds the model, so ShutdownModel could answer 404 instead of a misleading 500. That narrowed a shared adapter contract to serve one caller and broke the documented idempotent-unload guarantee, which CI caught on PR #10956: [FAIL] Node Backend Lifecycle (NATS-driven) > NATS backend.stop events should be no-op for models not on any node [Distributed] Expected success, but got: model not loaded on any node The spec name states the contract outright. The matching unit assertion was updated in that commit; this e2e one was missed because it lives under tests/e2e/ with no build tags and does not run in package-scoped test runs. Caller audit - who breaks when an idempotent unload becomes an error: - pkg/model/watchdog.go:902 (LRU memory reclaimer) is the serious one. It untracks a model ONLY when shutdown returns nil or ErrModelNotFound. A new error type means the model is never untracked, so the reclaimer keeps re-selecting the same entry and never reclaims - a live wedge whenever a local store entry outlives the remote model. - core/services/galleryop/managers_local.go:43 (DeleteModel) would warn on every deletion of an already-unloaded model. - core/services/modeladmin/{state,config,remote_sync}.go stop instances best-effort against models that are frequently not loaded. - deleteProcess itself: the no-local-process branch returns the unload result directly, so a stale local entry for a model no longer on any node turned a previously-successful cleanup into a failure. Only ShutdownModel wants the distinction, and only on the local-store-miss path. So the distinction moves to the caller instead of the contract: - UnloadRemoteModel/UnloadRemoteModelContext return nil again when no node has the model, and ErrRemoteModelNotLoaded is removed. - New optional RemoteModelPresenceChecker (HasRemoteModel) answers the question directly. deleteProcess consults it BEFORE unloading, because an idempotent unload cannot report afterwards whether anything was stopped. Absent locally AND cluster-wide is the only case that reports 404. - A failed registry lookup is surfaced rather than reported as absence: an unreachable registry is not evidence a model is gone, and answering a confident 404 off a failed lookup is how an operator gets told a running model does not exist. - Unloaders that predate the extension keep working - deleteProcess attempts the unload rather than refusing it - and compile-time assertions in the nodes package now pin all three optional interfaces, since both are consumed by runtime type assertion where drift degrades behavior silently instead of failing the build. The contract is now pinned at both levels that disagreed, each spec pointing at the other: "with no nodes returns nil" in unloader_test.go and "should be no-op for models not on any node" in node_lifecycle_test.go. Verified: full distributed e2e suite 233 passed / 0 failed (the suite that failed 232/1 in CI); pkg/model and core/services/nodes green; make lint new-from-merge-base reports 0 issues. Assisted-by: Claude:claude-opus-4-8 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 2035a4d commit 2b7a049

6 files changed

Lines changed: 170 additions & 35 deletions

File tree

core/services/nodes/unloader.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@ func (a *RemoteUnloaderAdapter) InstallTimeout() time.Duration {
7272
return a.installTimeout
7373
}
7474

75+
// Compile-time proof that the adapter still satisfies the loader's optional
76+
// extensions. Both are consumed via runtime type assertion in deleteProcess, so
77+
// a signature drift here would silently downgrade behavior — losing force
78+
// propagation, or making ShutdownModel unable to tell a cluster-wide miss from
79+
// a completed unload — rather than failing the build.
80+
var (
81+
_ model.RemoteModelUnloader = (*RemoteUnloaderAdapter)(nil)
82+
_ model.RemoteModelContextUnloader = (*RemoteUnloaderAdapter)(nil)
83+
_ model.RemoteModelPresenceChecker = (*RemoteUnloaderAdapter)(nil)
84+
)
85+
7586
// UnloadRemoteModel finds the node(s) hosting the given model and tells them
7687
// to stop their backend process via NATS backend.stop event.
7788
// The worker process handles a bounded Free() followed by process termination;
@@ -81,6 +92,22 @@ func (a *RemoteUnloaderAdapter) UnloadRemoteModel(modelName string) error {
8192
return a.UnloadRemoteModelContext(context.Background(), modelName, false)
8293
}
8394

95+
// HasRemoteModel reports whether any node currently holds the model. It exists
96+
// because UnloadRemoteModel is idempotent and so cannot signal "there was
97+
// nothing to stop"; ShutdownModel consults this first so it can answer 404 for
98+
// a model loaded neither locally nor anywhere in the cluster, instead of the
99+
// misleading 500 "model not found" that a local-store miss used to produce.
100+
func (a *RemoteUnloaderAdapter) HasRemoteModel(ctx context.Context, modelName string) (bool, error) {
101+
if ctx == nil {
102+
ctx = context.Background()
103+
}
104+
nodes, err := a.registry.FindNodesWithModel(ctx, modelName)
105+
if err != nil {
106+
return false, fmt.Errorf("finding nodes with model %q: %w", modelName, err)
107+
}
108+
return len(nodes) > 0, nil
109+
}
110+
84111
// UnloadRemoteModelContext is the cancellation-aware extension used by the
85112
// model loader to preserve forced shutdown across the distributed boundary.
86113
func (a *RemoteUnloaderAdapter) UnloadRemoteModelContext(ctx context.Context, modelName string, force bool) error {
@@ -92,12 +119,12 @@ func (a *RemoteUnloaderAdapter) UnloadRemoteModelContext(ctx context.Context, mo
92119
return fmt.Errorf("finding nodes with model %q: %w", modelName, err)
93120
}
94121
if len(nodes) == 0 {
95-
// Distinguish "no node has it" from "stopped successfully" so the
96-
// caller can report the truth to an operator. Returning nil here made
97-
// a no-op stop indistinguishable from a real one, and left the loader
98-
// unable to tell a cluster-wide miss from a completed unload.
122+
// Unloading is idempotent by contract: cleanup paths (model deletion,
123+
// config edits, watchdog eviction) legitimately run against an
124+
// already-unloaded model and must not fail. Callers that need to tell
125+
// this case apart use HasRemoteModel before unloading.
99126
xlog.Debug("No remote nodes found with model", "model", modelName)
100-
return model.ErrRemoteModelNotLoaded
127+
return nil
101128
}
102129

103130
var unloadErr error

core/services/nodes/unloader_test.go

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import (
1414

1515
"github.com/mudler/LocalAI/core/services/galleryop"
1616
"github.com/mudler/LocalAI/core/services/messaging"
17-
"github.com/mudler/LocalAI/pkg/model"
1817
)
1918

2019
// --- Fakes ---
@@ -127,14 +126,46 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
127126
adapter = NewRemoteUnloaderAdapter(locator, mc, 3*time.Minute, 15*time.Minute)
128127
})
129128

129+
// HasRemoteModel carries the distinction that UnloadRemoteModel
130+
// deliberately does not, so ShutdownModel can answer 404 for a model that
131+
// is loaded neither locally nor anywhere in the cluster without making the
132+
// shared unload path fail for every idempotent cleanup caller.
133+
Describe("HasRemoteModel", func() {
134+
It("reports false when no node has the model", func() {
135+
locator.nodes = nil
136+
loaded, err := adapter.HasRemoteModel(context.Background(), "my-model")
137+
Expect(err).ToNot(HaveOccurred())
138+
Expect(loaded).To(BeFalse())
139+
})
140+
141+
It("reports true when a node has the model", func() {
142+
locator.nodes = []BackendNode{{ID: "node-1", Name: "worker-1"}}
143+
loaded, err := adapter.HasRemoteModel(context.Background(), "my-model")
144+
Expect(err).ToNot(HaveOccurred())
145+
Expect(loaded).To(BeTrue())
146+
})
147+
148+
It("surfaces a registry failure instead of reporting absence", func() {
149+
// An unreachable registry is not evidence that the model is gone;
150+
// reporting false would let ShutdownModel answer a confident 404
151+
// on the strength of a failed lookup.
152+
locator.findErr = errors.New("registry unavailable")
153+
_, err := adapter.HasRemoteModel(context.Background(), "my-model")
154+
Expect(err).To(HaveOccurred())
155+
})
156+
})
157+
130158
Describe("UnloadRemoteModel", func() {
131-
It("with no nodes reports the model is not loaded", func() {
132-
// Previously returned nil, making "stopped it" and "there was
133-
// nothing to stop" indistinguishable. ShutdownModel relies on
134-
// this distinction to choose between success and a genuine 404,
135-
// so a cluster-wide miss must be reported, not swallowed.
159+
It("with no nodes returns nil", func() {
160+
// Unloading is idempotent: cleanup paths (model deletion, config
161+
// edits, watchdog eviction) legitimately run against an already
162+
// unloaded model, and turning that into an error wedges the
163+
// watchdog's LRU reclaimer, which only untracks a model when
164+
// shutdown reports success. The same contract is pinned end to end
165+
// by "should be no-op for models not on any node" in
166+
// tests/e2e/distributed/node_lifecycle_test.go — keep them in step.
136167
locator.nodes = nil
137-
Expect(adapter.UnloadRemoteModel("my-model")).To(MatchError(model.ErrRemoteModelNotLoaded))
168+
Expect(adapter.UnloadRemoteModel("my-model")).To(Succeed())
138169
Expect(mc.published).To(BeEmpty())
139170
})
140171

pkg/model/loader.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,20 @@ type RemoteModelContextUnloader interface {
4141
UnloadRemoteModelContext(ctx context.Context, modelName string, force bool) error
4242
}
4343

44+
// RemoteModelPresenceChecker reports whether any node in the cluster currently
45+
// holds the model.
46+
//
47+
// Unloading is deliberately idempotent: UnloadRemoteModel succeeds when no node
48+
// has the model, because cleanup paths (model deletion, config edits, watchdog
49+
// eviction) legitimately run against an already-unloaded model and must not
50+
// fail. That makes the unload result unable to distinguish "stopped it" from
51+
// "there was nothing to stop", so the one caller that needs the distinction —
52+
// ShutdownModel, which must report 404 rather than a misleading success for a
53+
// model loaded nowhere — asks separately, before unloading.
54+
type RemoteModelPresenceChecker interface {
55+
HasRemoteModel(ctx context.Context, modelName string) (bool, error)
56+
}
57+
4458
// ModelRouter is a callback that routes model loading to a remote node
4559
// instead of starting a local process. When set on the ModelLoader,
4660
// grpcModel() will delegate to this function before attempting local loading.

pkg/model/process.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,6 @@ var (
2626
// ErrModelBusy indicates that a graceful shutdown context ended while
2727
// requests were still in flight.
2828
ErrModelBusy = errors.New("model is still busy")
29-
30-
// ErrRemoteModelNotLoaded is returned by a RemoteModelUnloader when no
31-
// node in the cluster has the model loaded. It exists so the local store
32-
// miss and the cluster-wide miss stay distinguishable: only when BOTH are
33-
// empty may we tell the operator the model is not loaded.
34-
ErrRemoteModelNotLoaded = errors.New("model not loaded on any node")
3529
)
3630

3731
const (
@@ -85,14 +79,22 @@ func (ml *ModelLoader) deleteProcess(ctx context.Context, s string, force bool)
8579
// left its backend process untouched.
8680
if remoteUnloader != nil {
8781
xlog.Debug("Model not in local store; asking the remote unloader", "model", s)
88-
if err := unloadRemote(ctx, remoteUnloader, s, force); err != nil {
89-
if errors.Is(err, ErrRemoteModelNotLoaded) {
90-
// Absent locally AND cluster-wide: genuinely not loaded.
82+
// Ask BEFORE unloading. Unloading is idempotent by contract, so it
83+
// cannot afterwards tell us whether anything was actually stopped,
84+
// and only a model absent locally AND cluster-wide may be reported
85+
// as not found.
86+
if checker, ok := remoteUnloader.(RemoteModelPresenceChecker); ok {
87+
loaded, err := checker.HasRemoteModel(ctx, s)
88+
if err != nil {
89+
// An unreachable registry is not evidence of absence;
90+
// saying "not found" here would be a guess presented as fact.
91+
return fmt.Errorf("checking whether model %q is loaded on any node: %w", s, err)
92+
}
93+
if !loaded {
9194
return ErrModelNotFound
9295
}
93-
return err
9496
}
95-
return nil
97+
return unloadRemote(ctx, remoteUnloader, s, force)
9698
}
9799
xlog.Debug("Model not found", "model", s)
98100
return ErrModelNotFound

pkg/model/remote_shutdown_test.go

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,48 @@
11
package model_test
22

33
import (
4+
"context"
5+
"errors"
6+
47
"github.com/mudler/LocalAI/pkg/model"
58
"github.com/mudler/LocalAI/pkg/system"
69

710
. "github.com/onsi/ginkgo/v2"
811
. "github.com/onsi/gomega"
912
)
1013

11-
// fakeRemoteUnloader records the models it was asked to unload so the specs
12-
// can assert the remote path was actually taken (not merely that no error
13-
// surfaced).
14+
// fakeRemoteUnloader records the models it was asked to unload so the specs can
15+
// assert the remote path was actually taken (not merely that no error
16+
// surfaced). It mirrors the real adapter's contract: unloading is idempotent
17+
// and reports nil even when nothing was loaded, so presence is a separate
18+
// question.
1419
type fakeRemoteUnloader struct {
15-
called []string
16-
err error
20+
called []string
21+
unloadErr error
22+
23+
present bool
24+
presenceErr error
25+
asked []string
1726
}
1827

1928
func (f *fakeRemoteUnloader) UnloadRemoteModel(modelName string) error {
2029
f.called = append(f.called, modelName)
21-
return f.err
30+
return f.unloadErr
31+
}
32+
33+
func (f *fakeRemoteUnloader) HasRemoteModel(_ context.Context, modelName string) (bool, error) {
34+
f.asked = append(f.asked, modelName)
35+
return f.present, f.presenceErr
36+
}
37+
38+
// unloaderWithoutPresence is a RemoteModelUnloader that does NOT implement
39+
// RemoteModelPresenceChecker, pinning the compatibility path for third-party
40+
// implementations of the older interface.
41+
type unloaderWithoutPresence struct{ called []string }
42+
43+
func (u *unloaderWithoutPresence) UnloadRemoteModel(modelName string) error {
44+
u.called = append(u.called, modelName)
45+
return nil
2246
}
2347

2448
// In distributed mode the authoritative record of "is this model loaded" is
@@ -42,6 +66,7 @@ var _ = Describe("ShutdownModel in distributed mode", func() {
4266
})
4367

4468
It("delegates to the remote unloader when the model is not in the local store", func() {
69+
unloader.present = true
4570
modelLoader.SetRemoteUnloader(unloader)
4671

4772
err := modelLoader.ShutdownModel("longcat-video-avatar-1.5")
@@ -52,21 +77,48 @@ var _ = Describe("ShutdownModel in distributed mode", func() {
5277
"stopping a model that is running on a worker must succeed, not report 'model not found'")
5378
})
5479

55-
It("reports not-found only after the remote unloader confirms no node has it", func() {
56-
unloader.err = model.ErrRemoteModelNotLoaded
80+
It("reports not-found only after the registry confirms no node has it", func() {
81+
unloader.present = false
5782
modelLoader.SetRemoteUnloader(unloader)
5883

5984
err := modelLoader.ShutdownModel("never-loaded")
6085

61-
Expect(unloader.called).To(ConsistOf("never-loaded"),
86+
Expect(unloader.asked).To(ConsistOf("never-loaded"),
6287
"the registry must be consulted before declaring a model not found")
63-
Expect(err).To(HaveOccurred(),
64-
"a genuinely absent model must still error — silently succeeding would hide a failed stop")
88+
Expect(err).To(MatchError(model.ErrModelNotFound),
89+
"absent locally AND cluster-wide is the only case that may report not-found")
90+
Expect(unloader.called).To(BeEmpty(),
91+
"nothing to unload — no point publishing a stop for a model no node holds")
92+
})
93+
94+
It("does not claim not-found when the registry lookup fails", func() {
95+
// An unreachable registry is not evidence of absence. Reporting 404
96+
// here would tell an operator the model is gone on the strength of a
97+
// failed lookup.
98+
unloader.presenceErr = errors.New("registry unavailable")
99+
modelLoader.SetRemoteUnloader(unloader)
100+
101+
err := modelLoader.ShutdownModel("maybe-loaded")
102+
103+
Expect(err).To(HaveOccurred())
104+
Expect(err).ToNot(MatchError(model.ErrModelNotFound))
105+
})
106+
107+
It("still unloads via an unloader that cannot answer presence", func() {
108+
// Older RemoteModelUnloader implementations have no presence check.
109+
// They must keep working: attempt the unload rather than refusing it.
110+
legacy := &unloaderWithoutPresence{}
111+
modelLoader.SetRemoteUnloader(legacy)
112+
113+
err := modelLoader.ShutdownModel("some-model")
114+
115+
Expect(legacy.called).To(ConsistOf("some-model"))
116+
Expect(err).ToNot(HaveOccurred())
65117
})
66118

67119
It("still reports not-found when no remote unloader is configured", func() {
68120
// Single-node behavior must be unchanged.
69121
err := modelLoader.ShutdownModel("never-loaded")
70-
Expect(err).To(HaveOccurred())
122+
Expect(err).To(MatchError(model.ErrModelNotFound))
71123
})
72124
})

tests/e2e/distributed/node_lifecycle_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ var _ = Describe("Node Backend Lifecycle (NATS-driven)", Label("Distributed"), f
141141
})
142142

143143
It("should be no-op for models not on any node", func() {
144+
// Unloading is idempotent by contract: cleanup paths (model
145+
// deletion, config edits, watchdog eviction) legitimately run
146+
// against an already-unloaded model, and the watchdog's LRU
147+
// reclaimer only untracks a model when shutdown reports success.
148+
// Callers needing to tell "stopped it" from "nothing to stop" —
149+
// ShutdownModel, so it can answer 404 — use HasRemoteModel instead.
150+
// The same contract is pinned at unit level by "with no nodes
151+
// returns nil" in core/services/nodes/unloader_test.go; keep them
152+
// in step.
144153
adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
145154
Expect(adapter.UnloadRemoteModel("nonexistent-model")).To(Succeed())
146155
})

0 commit comments

Comments
 (0)