Skip to content

Commit 9c43b2d

Browse files
authored
fix(model): make backend shutdown model-scoped (#10865)
Avoid holding the global loader lock across backend lifecycle waits and propagate forced shutdown through distributed workers. Track parallel requests with in-flight counters and reserve worker ports until process termination. Add focused race tests and an authoritative FizzBee lifecycle model with a fail-closed conformance target. Assisted-by: Codex:GPT-5 [FizzBee] [Ginkgo] Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 27955e0 commit 9c43b2d

25 files changed

Lines changed: 1417 additions & 348 deletions

Makefile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,8 +412,13 @@ test-realtime: build-mock-backend
412412
test-realtime-conformance:
413413
GOCMD=$(GOCMD) ./scripts/realtime-conformance.sh
414414

415+
# Verify the shared model-loader shutdown behavior independently of any API
416+
# modality (focused loader/gRPC/distributed/worker tests under -race + FizzBee).
417+
test-model-lifecycle-conformance:
418+
GOCMD=$(GOCMD) ./scripts/model-lifecycle-conformance.sh
419+
415420
# Install the pinned, checksum-verified FizzBee model checker (into .tools/,
416-
# gitignored) used by test-realtime-conformance. Idempotent; no-op if present.
421+
# gitignored) used by the conformance targets. Idempotent; no-op if present.
417422
install-fizzbee:
418423
./scripts/install-fizzbee.sh
419424

core/services/messaging/subjects.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,17 @@ type NodeBackendInfo struct {
258258
Digest string `json:"digest,omitempty"`
259259
}
260260

261+
// BackendStopRequest controls worker-side process shutdown. Force skips the
262+
// best-effort Free RPC so a backend stuck serving a request can still be
263+
// terminated by the watchdog.
264+
type BackendStopRequest struct {
265+
Backend string `json:"backend"`
266+
Force bool `json:"force,omitempty"`
267+
}
268+
261269
// SubjectNodeBackendStop tells a worker node to stop its gRPC backend process.
262270
// Equivalent to the local deleteProcess(). The node will:
263-
// 1. Best-effort Free() via gRPC
271+
// 1. Best-effort bounded Free() via gRPC (unless Force is true)
264272
// 2. Kill the backend process
265273
// 3. Can be restarted via another backend.start event.
266274
func SubjectNodeBackendStop(nodeID string) string {

core/services/nodes/unloader.go

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@ import (
1515
"github.com/mudler/xlog"
1616
)
1717

18-
// backendStopRequest is the request payload for backend.stop (fire-and-forget).
19-
type backendStopRequest struct {
20-
Backend string `json:"backend"`
21-
}
22-
2318
// NodeCommandSender abstracts NATS-based commands to worker nodes.
2419
// Used by HTTP endpoint handlers to avoid coupling to the concrete RemoteUnloaderAdapter.
2520
//
@@ -78,28 +73,44 @@ func (a *RemoteUnloaderAdapter) InstallTimeout() time.Duration {
7873

7974
// UnloadRemoteModel finds the node(s) hosting the given model and tells them
8075
// to stop their backend process via NATS backend.stop event.
81-
// The worker process handles: Free() → kill process.
76+
// The worker process handles a bounded Free() followed by process termination;
77+
// forced shutdown skips Free().
8278
// This is called by ModelLoader.deleteProcess() when process == nil (remote model).
8379
func (a *RemoteUnloaderAdapter) UnloadRemoteModel(modelName string) error {
84-
ctx := context.Background()
80+
return a.UnloadRemoteModelContext(context.Background(), modelName, false)
81+
}
82+
83+
// UnloadRemoteModelContext is the cancellation-aware extension used by the
84+
// model loader to preserve forced shutdown across the distributed boundary.
85+
func (a *RemoteUnloaderAdapter) UnloadRemoteModelContext(ctx context.Context, modelName string, force bool) error {
86+
if ctx == nil {
87+
ctx = context.Background()
88+
}
8589
nodes, err := a.registry.FindNodesWithModel(ctx, modelName)
86-
if err != nil || len(nodes) == 0 {
90+
if err != nil {
91+
return fmt.Errorf("finding nodes with model %q: %w", modelName, err)
92+
}
93+
if len(nodes) == 0 {
8794
xlog.Debug("No remote nodes found with model", "model", modelName)
8895
return nil
8996
}
9097

98+
var unloadErr error
9199
for _, node := range nodes {
92-
xlog.Info("Sending NATS backend.stop to node", "model", modelName, "node", node.Name, "nodeID", node.ID)
93-
if err := a.StopBackend(node.ID, modelName); err != nil {
100+
xlog.Info("Sending NATS backend.stop to node", "model", modelName, "node", node.Name, "nodeID", node.ID, "force", force)
101+
if err := a.stopBackend(node.ID, modelName, force); err != nil {
94102
xlog.Warn("Failed to send backend.stop", "node", node.Name, "error", err)
103+
unloadErr = errors.Join(unloadErr, fmt.Errorf("stopping model on node %s: %w", node.ID, err))
95104
continue
96105
}
97106
// Remove every replica of this model on the node — the worker will
98107
// handle the actual process cleanup.
99-
a.registry.RemoveAllNodeModelReplicas(ctx, node.ID, modelName)
108+
if err := a.registry.RemoveAllNodeModelReplicas(ctx, node.ID, modelName); err != nil {
109+
unloadErr = errors.Join(unloadErr, fmt.Errorf("removing model replicas from node %s: %w", node.ID, err))
110+
}
100111
}
101112

102-
return nil
113+
return unloadErr
103114
}
104115

105116
// InstallBackend sends a backend.install request-reply to a worker node.
@@ -142,7 +153,9 @@ func (a *RemoteUnloaderAdapter) InstallBackend(
142153
}, a.installTimeout)
143154

144155
if sub != nil {
145-
_ = sub.Unsubscribe()
156+
if unsubscribeErr := sub.Unsubscribe(); unsubscribeErr != nil {
157+
xlog.Warn("Failed to unsubscribe from backend install progress", "nodeID", nodeID, "backend", backendType, "opID", opID, "error", unsubscribeErr)
158+
}
146159
}
147160

148161
if err != nil && isNATSTimeout(err) {
@@ -216,7 +229,9 @@ func (a *RemoteUnloaderAdapter) UpgradeBackend(nodeID, backendType, galleriesJSO
216229
}, a.upgradeTimeout)
217230

218231
if sub != nil {
219-
_ = sub.Unsubscribe()
232+
if unsubscribeErr := sub.Unsubscribe(); unsubscribeErr != nil {
233+
xlog.Warn("Failed to unsubscribe from backend upgrade progress", "nodeID", nodeID, "backend", backendType, "opID", opID, "error", unsubscribeErr)
234+
}
220235
}
221236

222237
if err != nil && isNATSTimeout(err) {
@@ -250,7 +265,9 @@ func (a *RemoteUnloaderAdapter) installWithForceFallback(nodeID, backendType, ga
250265
}, a.upgradeTimeout)
251266

252267
if sub != nil {
253-
_ = sub.Unsubscribe()
268+
if unsubscribeErr := sub.Unsubscribe(); unsubscribeErr != nil {
269+
xlog.Warn("Failed to unsubscribe from legacy backend install progress", "nodeID", nodeID, "backend", backendType, "opID", opID, "error", unsubscribeErr)
270+
}
254271
}
255272

256273
if err != nil && isNATSTimeout(err) {
@@ -272,14 +289,15 @@ func (a *RemoteUnloaderAdapter) ListBackends(nodeID string) (*messaging.BackendL
272289
// If backend is empty, the worker stops ALL backends.
273290
// The node stays registered and can receive another InstallBackend later.
274291
func (a *RemoteUnloaderAdapter) StopBackend(nodeID, backend string) error {
292+
return a.stopBackend(nodeID, backend, false)
293+
}
294+
295+
func (a *RemoteUnloaderAdapter) stopBackend(nodeID, backend string, force bool) error {
275296
subject := messaging.SubjectNodeBackendStop(nodeID)
276-
if backend == "" {
297+
if backend == "" && !force {
277298
return a.nats.Publish(subject, nil)
278299
}
279-
req := struct {
280-
Backend string `json:"backend"`
281-
}{Backend: backend}
282-
return a.nats.Publish(subject, req)
300+
return a.nats.Publish(subject, messaging.BackendStopRequest{Backend: backend, Force: force})
283301
}
284302

285303
// DeleteBackend tells a worker node to delete a backend (stop + remove files).

core/services/nodes/unloader_test.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,23 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
160160
failOnce := &failOnceMessagingClient{inner: mc, failOn: 0}
161161
adapter = NewRemoteUnloaderAdapter(locator, failOnce, 3*time.Minute, 15*time.Minute)
162162

163-
Expect(adapter.UnloadRemoteModel("llama")).To(Succeed())
163+
Expect(adapter.UnloadRemoteModel("llama")).To(HaveOccurred())
164164

165165
// The second node should still have been processed.
166166
// The first node's StopBackend errored, so RemoveNodeModel was NOT called for it.
167167
// The second node's StopBackend succeeded, so RemoveNodeModel WAS called.
168168
Expect(locator.removedPairs).To(HaveLen(1))
169169
Expect(locator.removedPairs[0].nodeID).To(Equal("node-ok"))
170170
})
171+
172+
It("propagates forced shutdown to every worker", func() {
173+
locator.nodes = []BackendNode{{ID: "node-1", Name: "worker-1"}}
174+
Expect(adapter.UnloadRemoteModelContext(context.Background(), "llama", true)).To(Succeed())
175+
176+
var payload messaging.BackendStopRequest
177+
Expect(json.Unmarshal(mc.published[0].Data, &payload)).To(Succeed())
178+
Expect(payload).To(Equal(messaging.BackendStopRequest{Backend: "llama", Force: true}))
179+
})
171180
})
172181

173182
Describe("StopBackend", func() {
@@ -182,11 +191,10 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
182191
Expect(adapter.StopBackend("node-1", "llama-backend")).To(Succeed())
183192
Expect(mc.published).To(HaveLen(1))
184193

185-
var payload struct {
186-
Backend string `json:"backend"`
187-
}
194+
var payload messaging.BackendStopRequest
188195
Expect(json.Unmarshal(mc.published[0].Data, &payload)).To(Succeed())
189196
Expect(payload.Backend).To(Equal("llama-backend"))
197+
Expect(payload.Force).To(BeFalse())
190198
})
191199
})
192200

core/services/worker/addr_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ var _ = Describe("Worker address resolution", func() {
1919
Entry("falls back to ServeAddr", "", "0.0.0.0:50051", 50051),
2020
Entry("returns 50051 when neither set", "", "", 50051),
2121
Entry("Addr with custom port", "10.0.0.5:7000", "", 7000),
22+
Entry("supports bracketed IPv6", "[2001:db8::1]:7001", "", 7001),
2223
Entry("invalid port in Addr falls through to ServeAddr", "host:notanumber", "0.0.0.0:9999", 9999),
24+
Entry("out-of-range port in Addr falls through to ServeAddr", "host:70000", "0.0.0.0:9998", 9998),
2325
)
2426
})
2527

core/services/worker/file_staging.go

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ func (cfg *Config) subscribeFileStaging(natsClient messaging.MessagingClient, no
5959
}
6060

6161
// Subscribe: files.ensure — download S3 key to local, reply with local path
62-
natsClient.SubscribeReply(messaging.SubjectNodeFilesEnsure(nodeID), func(data []byte, reply func([]byte)) {
62+
if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesEnsure(nodeID), func(data []byte, reply func([]byte)) {
6363
var req struct {
6464
Key string `json:"key"`
6565
}
@@ -77,10 +77,12 @@ func (cfg *Config) subscribeFileStaging(natsClient messaging.MessagingClient, no
7777

7878
xlog.Debug("File ensured locally", "key", req.Key, "path", localPath)
7979
replyJSON(reply, map[string]string{"local_path": localPath})
80-
})
80+
}); err != nil {
81+
return fmt.Errorf("subscribing to files.ensure events: %w", err)
82+
}
8183

8284
// Subscribe: files.stage — upload local path to S3, reply with key
83-
natsClient.SubscribeReply(messaging.SubjectNodeFilesStage(nodeID), func(data []byte, reply func([]byte)) {
85+
if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesStage(nodeID), func(data []byte, reply func([]byte)) {
8486
var req struct {
8587
LocalPath string `json:"local_path"`
8688
Key string `json:"key"`
@@ -107,10 +109,12 @@ func (cfg *Config) subscribeFileStaging(natsClient messaging.MessagingClient, no
107109

108110
xlog.Debug("File staged to S3", "path", req.LocalPath, "key", req.Key)
109111
replyJSON(reply, map[string]string{"key": req.Key})
110-
})
112+
}); err != nil {
113+
return fmt.Errorf("subscribing to files.stage events: %w", err)
114+
}
111115

112116
// Subscribe: files.temp — allocate temp file, reply with local path
113-
natsClient.SubscribeReply(messaging.SubjectNodeFilesTemp(nodeID), func(data []byte, reply func([]byte)) {
117+
if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesTemp(nodeID), func(data []byte, reply func([]byte)) {
114118
tmpDir := filepath.Join(cacheDir, "staging-tmp")
115119
if err := os.MkdirAll(tmpDir, 0750); err != nil {
116120
replyJSON(reply, map[string]string{"error": fmt.Sprintf("creating temp dir: %v", err)})
@@ -123,14 +127,19 @@ func (cfg *Config) subscribeFileStaging(natsClient messaging.MessagingClient, no
123127
return
124128
}
125129
localPath := f.Name()
126-
f.Close()
130+
if err := f.Close(); err != nil {
131+
replyJSON(reply, map[string]string{"error": fmt.Sprintf("closing temp file: %v", err)})
132+
return
133+
}
127134

128135
xlog.Debug("Allocated temp file", "path", localPath)
129136
replyJSON(reply, map[string]string{"local_path": localPath})
130-
})
137+
}); err != nil {
138+
return fmt.Errorf("subscribing to files.temp events: %w", err)
139+
}
131140

132141
// Subscribe: files.listdir — list files in a local directory, reply with relative paths
133-
natsClient.SubscribeReply(messaging.SubjectNodeFilesListDir(nodeID), func(data []byte, reply func([]byte)) {
142+
if _, err := natsClient.SubscribeReply(messaging.SubjectNodeFilesListDir(nodeID), func(data []byte, reply func([]byte)) {
134143
var req struct {
135144
KeyPrefix string `json:"key_prefix"`
136145
}
@@ -163,22 +172,29 @@ func (cfg *Config) subscribeFileStaging(natsClient messaging.MessagingClient, no
163172
}
164173

165174
var files []string
166-
filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
175+
if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
167176
if err != nil {
168-
return nil
177+
return err
169178
}
170179
if !d.IsDir() {
171180
rel, err := filepath.Rel(dirPath, path)
172-
if err == nil {
173-
files = append(files, rel)
181+
if err != nil {
182+
return err
174183
}
184+
files = append(files, rel)
175185
}
176186
return nil
177-
})
187+
}); err != nil {
188+
xlog.Error("Failed to list staged files", "keyPrefix", req.KeyPrefix, "dirPath", dirPath, "error", err)
189+
replyJSON(reply, map[string]any{"error": err.Error()})
190+
return
191+
}
178192

179193
xlog.Debug("Listed remote dir", "keyPrefix", req.KeyPrefix, "dirPath", dirPath, "fileCount", len(files))
180194
replyJSON(reply, map[string]any{"files": files})
181-
})
195+
}); err != nil {
196+
return fmt.Errorf("subscribing to files.listdir events: %w", err)
197+
}
182198

183199
xlog.Info("Subscribed to file staging NATS subjects", "nodeID", nodeID)
184200
return nil

core/services/worker/install.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest,
7878
}
7979
xlog.Warn("Stale process entry for backend (dead process); cleaning up before reinstall",
8080
"backend", req.Backend, "model", req.ModelID, "replica", req.ReplicaIndex, "addr", addr)
81-
s.stopBackendExact(processKey)
81+
s.stopBackendExact(processKey, false)
8282
}
8383
} else {
8484
// Upgrade path: stop every live process that shares this backend so the
@@ -95,17 +95,18 @@ func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest,
9595
for _, key := range toStop {
9696
xlog.Info("Force install: stopping running backend before reinstall",
9797
"backend", req.Backend, "processKey", key)
98-
s.stopBackendExact(key)
98+
s.stopBackendExact(key, true)
9999
}
100100
}
101101

102102
// Parse galleries from request (override local config if provided)
103103
galleries := s.galleries
104104
if req.BackendGalleries != "" {
105105
var reqGalleries []config.Gallery
106-
if err := json.Unmarshal([]byte(req.BackendGalleries), &reqGalleries); err == nil {
107-
galleries = reqGalleries
106+
if err := json.Unmarshal([]byte(req.BackendGalleries), &reqGalleries); err != nil {
107+
return "", fmt.Errorf("decoding backend galleries: %w", err)
108108
}
109+
galleries = reqGalleries
109110
}
110111

111112
// When the master tagged this install with an OpID, stream the
@@ -147,7 +148,9 @@ func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest,
147148
}
148149
}
149150
// Re-register after install and retry
150-
gallery.RegisterBackends(s.systemState, s.ml)
151+
if err := gallery.RegisterBackends(s.systemState, s.ml); err != nil {
152+
return "", fmt.Errorf("refreshing registered backends after install: %w", err)
153+
}
151154
backendPath = s.findBackend(req.Backend)
152155
}
153156

@@ -175,15 +178,16 @@ func (s *backendSupervisor) upgradeBackend(req messaging.BackendUpgradeRequest)
175178
for _, key := range toStop {
176179
xlog.Info("Upgrade: stopping running backend before reinstall",
177180
"backend", req.Backend, "processKey", key)
178-
s.stopBackendExact(key)
181+
s.stopBackendExact(key, true)
179182
}
180183

181184
galleries := s.galleries
182185
if req.BackendGalleries != "" {
183186
var reqGalleries []config.Gallery
184-
if err := json.Unmarshal([]byte(req.BackendGalleries), &reqGalleries); err == nil {
185-
galleries = reqGalleries
187+
if err := json.Unmarshal([]byte(req.BackendGalleries), &reqGalleries); err != nil {
188+
return fmt.Errorf("decoding backend galleries: %w", err)
186189
}
190+
galleries = reqGalleries
187191
}
188192

189193
// When the master tagged this upgrade with an OpID, stream gallery download
@@ -215,7 +219,9 @@ func (s *backendSupervisor) upgradeBackend(req messaging.BackendUpgradeRequest)
215219
}
216220
}
217221

218-
gallery.RegisterBackends(s.systemState, s.ml)
222+
if err := gallery.RegisterBackends(s.systemState, s.ml); err != nil {
223+
return fmt.Errorf("refreshing registered backends after upgrade: %w", err)
224+
}
219225
return nil
220226
}
221227

0 commit comments

Comments
 (0)