Skip to content

Commit 569d9bb

Browse files
localai-botmudler
andauthored
fix(distributed): broadcast file-staging progress across replicas (#10440)
File-staging progress lived only in the SmartRouter's in-memory StagingTracker on the replica performing the transfer. In a multi-replica deployment behind a round-robin load balancer, a /api/operations poll that lands on any other replica saw no staging row, so the progress ("processing file ... Total ... Current ...") flickered in and out as polls rotated between frontends. Mirror the pattern already used for gallery-install progress: the origin replica broadcasts staging ticks over NATS (SubjectStagingProgress, a new staging.<model>.progress subject), and peers merge them via ApplyRemote (SubscribeBroadcasts on the wildcard). Byte-level ticks are leading-edge debounced (~1/s); Start/FileComplete/Complete always publish. A locally-owned op stays authoritative so the origin's own echo and stray peer events can't clobber it, and mirrored remote ops expire after a TTL so a missed Done event can't leave a phantom row. The UI read path (StagingTracker.GetAll) is unchanged. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 682fb27 commit 569d9bb

4 files changed

Lines changed: 317 additions & 34 deletions

File tree

core/application/distributed.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
357357
Pressure: pressure,
358358
})
359359

360+
// Wire staging-progress broadcasting so file-staging shows up on every
361+
// replica, not just the one performing the transfer. Without this, a
362+
// /api/operations poll that round-robins onto a peer sees no staging row and
363+
// the progress flickers. The origin publishes; peers mirror via the wildcard.
364+
router.StagingTracker().SetPublisher(natsClient)
365+
if _, err := router.StagingTracker().SubscribeBroadcasts(natsClient); err != nil {
366+
xlog.Warn("Failed to subscribe to staging progress broadcasts", "error", err)
367+
}
368+
360369
// Create ReplicaReconciler for auto-scaling model replicas. Adapter +
361370
// RegistrationToken feed the state-reconciliation passes: pending op
362371
// drain uses the adapter, and model health probes use the token to auth

core/services/messaging/subjects.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ func SubjectGalleryProgress(opID string) string {
6464
return subjectGalleryPrefix + sanitizeSubjectToken(opID) + ".progress"
6565
}
6666

67+
// SubjectStagingProgress returns the NATS subject a frontend replica publishes
68+
// file-staging progress on. Staging progress is otherwise per-process state
69+
// (the SmartRouter's in-memory StagingTracker), so without this broadcast a
70+
// /api/operations poll that round-robins onto a replica that did not originate
71+
// the staging op sees nothing - the progress row flickers in multi-replica
72+
// deployments. Peers subscribe to the wildcard and merge.
73+
func SubjectStagingProgress(modelID string) string {
74+
return subjectStagingPrefix + sanitizeSubjectToken(modelID) + ".progress"
75+
}
76+
77+
const subjectStagingPrefix = "staging."
78+
79+
// SubjectStagingProgressWildcard matches every replica's staging-progress
80+
// broadcasts so a peer can mirror staging ops it did not originate.
81+
const SubjectStagingProgressWildcard = "staging.*.progress"
82+
6783
// SubjectGalleryOpStart and SubjectGalleryOpEnd are broadcast subjects for the
6884
// in-memory OpCache lifecycle. Frontend replicas publish to these when an
6985
// admin admits a new install/delete (Start) and when an operation is

core/services/nodes/staging_progress.go

Lines changed: 183 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,58 +5,138 @@ import (
55
"fmt"
66
"sync"
77
"time"
8+
9+
"github.com/mudler/LocalAI/core/services/messaging"
810
)
911

1012
// StagingStatus represents the current progress of a model staging operation.
1113
type StagingStatus struct {
12-
ModelID string `json:"model_id"`
13-
NodeName string `json:"node_name"`
14-
FileName string `json:"file_name"`
15-
BytesSent int64 `json:"bytes_sent"`
16-
TotalBytes int64 `json:"total_bytes"`
17-
Progress float64 `json:"progress"` // 0-100 overall progress
18-
Speed string `json:"speed"`
19-
FileIndex int `json:"file_index"`
20-
TotalFiles int `json:"total_files"`
21-
Message string `json:"message"`
14+
ModelID string `json:"model_id"`
15+
NodeName string `json:"node_name"`
16+
FileName string `json:"file_name"`
17+
BytesSent int64 `json:"bytes_sent"`
18+
TotalBytes int64 `json:"total_bytes"`
19+
Progress float64 `json:"progress"` // 0-100 overall progress
20+
Speed string `json:"speed"`
21+
FileIndex int `json:"file_index"`
22+
TotalFiles int `json:"total_files"`
23+
Message string `json:"message"`
2224
StartedAt time.Time `json:"started_at"`
2325
}
2426

27+
const (
28+
// stagingBroadcastInterval bounds how often byte-level UpdateFile ticks are
29+
// re-broadcast to peers (leading-edge debounce). State transitions (Start,
30+
// FileComplete, Complete) always publish so peers never miss them.
31+
stagingBroadcastInterval = time.Second
32+
// stagingRemoteTTL drops a mirrored (remote) op whose last update is older
33+
// than this. NATS pub/sub is fire-and-forget, so a missed Done event would
34+
// otherwise leave a phantom staging row on a peer forever; a live op
35+
// refreshes its mirror at least every stagingBroadcastInterval.
36+
stagingRemoteTTL = 60 * time.Second
37+
)
38+
39+
// stagingEntry wraps a StagingStatus with the bookkeeping needed to keep peer
40+
// replicas consistent: whether this op is mirrored from a peer (remote) vs.
41+
// owned locally, when it was last updated (for remote-mirror expiry), and when
42+
// its byte progress was last broadcast (for debounce).
43+
type stagingEntry struct {
44+
status StagingStatus
45+
remote bool
46+
updatedAt time.Time
47+
lastPub time.Time
48+
}
49+
2550
// StagingTracker tracks active file staging operations in-memory.
2651
// Used by SmartRouter to publish progress and by /api/operations to surface it.
52+
//
53+
// In distributed mode each frontend replica runs its own tracker. The replica
54+
// performing a transfer owns the op locally and broadcasts progress over NATS
55+
// (SetPublisher); peers mirror it via ApplyRemote (SubscribeBroadcasts) so a
56+
// /api/operations poll that round-robins onto any replica surfaces the op.
2757
type StagingTracker struct {
28-
mu sync.RWMutex
29-
active map[string]*StagingStatus
58+
mu sync.RWMutex
59+
active map[string]*stagingEntry
60+
publisher messaging.Publisher
61+
}
62+
63+
// StagingProgressEvent is the wire payload a frontend replica broadcasts on
64+
// SubjectStagingProgress so peer replicas can mirror a staging op they did not
65+
// originate. Done signals the op finished (peers drop their mirrored copy).
66+
type StagingProgressEvent struct {
67+
ModelID string `json:"model_id"`
68+
Status *StagingStatus `json:"status,omitempty"`
69+
Done bool `json:"done"`
3070
}
3171

3272
// NewStagingTracker creates a new tracker.
3373
func NewStagingTracker() *StagingTracker {
3474
return &StagingTracker{
35-
active: make(map[string]*StagingStatus),
75+
active: make(map[string]*stagingEntry),
3676
}
3777
}
3878

79+
// SetPublisher wires the NATS publisher used to broadcast staging progress to
80+
// peer replicas. No-op publisher (nil) keeps the tracker standalone.
81+
func (t *StagingTracker) SetPublisher(p messaging.Publisher) {
82+
t.mu.Lock()
83+
defer t.mu.Unlock()
84+
t.publisher = p
85+
}
86+
87+
// SubscribeBroadcasts subscribes to peer replicas' staging-progress broadcasts
88+
// and mirrors them into this tracker, so /api/operations on any replica surfaces
89+
// staging ops it did not originate. Returns the subscription for cleanup.
90+
func (t *StagingTracker) SubscribeBroadcasts(nc messaging.MessagingClient) (messaging.Subscription, error) {
91+
return messaging.SubscribeJSON(nc, messaging.SubjectStagingProgressWildcard, func(evt StagingProgressEvent) {
92+
if evt.ModelID == "" {
93+
return
94+
}
95+
t.ApplyRemote(evt)
96+
})
97+
}
98+
99+
// publishStaging emits an event to the per-model staging subject. The publisher
100+
// is captured by the caller under the lock and passed in, so publishing happens
101+
// outside the lock (a slow NATS link must not stall the staging copy loop).
102+
func publishStaging(p messaging.Publisher, evt StagingProgressEvent) {
103+
if p == nil {
104+
return
105+
}
106+
_ = p.Publish(messaging.SubjectStagingProgress(evt.ModelID), evt)
107+
}
108+
39109
// Start registers a new staging operation for the given model.
40110
func (t *StagingTracker) Start(modelID, nodeName string, totalFiles int) {
41111
t.mu.Lock()
42-
defer t.mu.Unlock()
43-
t.active[modelID] = &StagingStatus{
44-
ModelID: modelID,
45-
NodeName: nodeName,
46-
TotalFiles: totalFiles,
47-
StartedAt: time.Now(),
48-
Message: "Preparing to stage model files",
112+
e := &stagingEntry{
113+
status: StagingStatus{
114+
ModelID: modelID,
115+
NodeName: nodeName,
116+
TotalFiles: totalFiles,
117+
StartedAt: time.Now(),
118+
Message: "Preparing to stage model files",
119+
},
120+
updatedAt: time.Now(),
121+
// lastPub stays zero so the first UpdateFile tick always broadcasts.
49122
}
123+
t.active[modelID] = e
124+
pub := t.publisher
125+
snap := e.status
126+
t.mu.Unlock()
127+
128+
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Status: &snap})
50129
}
51130

52131
// UpdateFile updates the tracker with current file transfer progress.
53132
func (t *StagingTracker) UpdateFile(modelID, fileName string, fileIndex int, bytesSent, totalBytes int64, speed string) {
54133
t.mu.Lock()
55-
defer t.mu.Unlock()
56-
s, ok := t.active[modelID]
134+
e, ok := t.active[modelID]
57135
if !ok {
136+
t.mu.Unlock()
58137
return
59138
}
139+
s := &e.status
60140
s.FileName = fileName
61141
s.FileIndex = fileIndex
62142
s.BytesSent = bytesSent
@@ -79,52 +159,121 @@ func (t *StagingTracker) UpdateFile(modelID, fileName string, fileIndex int, byt
79159
} else {
80160
s.Message = fmt.Sprintf("Staging %s", fileName)
81161
}
162+
163+
e.updatedAt = time.Now()
164+
// Leading-edge debounce: byte ticks fire many times per second; only
165+
// re-broadcast at most once per stagingBroadcastInterval.
166+
var pub messaging.Publisher
167+
var snap StagingStatus
168+
if time.Since(e.lastPub) >= stagingBroadcastInterval {
169+
e.lastPub = time.Now()
170+
pub = t.publisher
171+
snap = e.status
172+
}
173+
t.mu.Unlock()
174+
175+
if pub != nil {
176+
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Status: &snap})
177+
}
82178
}
83179

84180
// FileComplete marks a single file as done within a staging operation.
85181
func (t *StagingTracker) FileComplete(modelID string, fileIndex, totalFiles int) {
86182
t.mu.Lock()
87-
defer t.mu.Unlock()
88-
s, ok := t.active[modelID]
183+
e, ok := t.active[modelID]
89184
if !ok {
185+
t.mu.Unlock()
90186
return
91187
}
188+
s := &e.status
92189
if totalFiles > 0 {
93190
s.Progress = float64(fileIndex) / float64(totalFiles) * 100
94191
}
95192
s.BytesSent = 0
96193
s.TotalBytes = 0
97194
s.Speed = ""
195+
e.updatedAt = time.Now()
196+
e.lastPub = time.Now()
197+
pub := t.publisher
198+
snap := e.status
199+
t.mu.Unlock()
200+
201+
// Always broadcast a per-file completion so peers' progress bars advance.
202+
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Status: &snap})
98203
}
99204

100205
// Complete removes a staging operation (it's done).
101206
func (t *StagingTracker) Complete(modelID string) {
102207
t.mu.Lock()
103-
defer t.mu.Unlock()
208+
_, ok := t.active[modelID]
104209
delete(t.active, modelID)
210+
pub := t.publisher
211+
t.mu.Unlock()
212+
213+
if ok {
214+
// Tell peers to drop their mirrored copy.
215+
publishStaging(pub, StagingProgressEvent{ModelID: modelID, Done: true})
216+
}
217+
}
218+
219+
// ApplyRemote merges a peer replica's staging broadcast into this tracker. It
220+
// never re-broadcasts (no echo loop). A locally-owned op is authoritative: a
221+
// remote event for the same model is ignored, so the origin replica receiving
222+
// its own broadcast (and any stray peer event) cannot clobber or delete it.
223+
func (t *StagingTracker) ApplyRemote(evt StagingProgressEvent) {
224+
t.mu.Lock()
225+
defer t.mu.Unlock()
226+
227+
if existing, ok := t.active[evt.ModelID]; ok && !existing.remote {
228+
// We own this op locally — ignore peer chatter about it.
229+
return
230+
}
231+
if evt.Done {
232+
delete(t.active, evt.ModelID)
233+
return
234+
}
235+
if evt.Status == nil {
236+
return
237+
}
238+
t.active[evt.ModelID] = &stagingEntry{
239+
status: *evt.Status,
240+
remote: true,
241+
updatedAt: time.Now(),
242+
}
105243
}
106244

107-
// GetAll returns a snapshot of all active staging operations.
245+
// GetAll returns a snapshot of all active staging operations. Stale remote
246+
// mirrors (a peer op whose Done event was missed) are pruned here so they don't
247+
// linger in the UI.
108248
func (t *StagingTracker) GetAll() map[string]StagingStatus {
109-
t.mu.RLock()
110-
defer t.mu.RUnlock()
249+
t.mu.Lock()
250+
defer t.mu.Unlock()
251+
now := time.Now()
111252
result := make(map[string]StagingStatus, len(t.active))
112-
for k, v := range t.active {
113-
result[k] = *v
253+
for k, e := range t.active {
254+
if e.remote && now.Sub(e.updatedAt) > stagingRemoteTTL {
255+
delete(t.active, k)
256+
continue
257+
}
258+
result[k] = e.status
114259
}
115260
return result
116261
}
117262

118-
// Get returns the status of a specific staging operation, or nil if not active.
263+
// Get returns the status of a specific staging operation, or nil if not active
264+
// (or a stale remote mirror).
119265
func (t *StagingTracker) Get(modelID string) *StagingStatus {
120266
t.mu.RLock()
121267
defer t.mu.RUnlock()
122-
s, ok := t.active[modelID]
268+
e, ok := t.active[modelID]
123269
if !ok {
124270
return nil
125271
}
126-
copy := *s
127-
return &copy
272+
if e.remote && time.Since(e.updatedAt) > stagingRemoteTTL {
273+
return nil
274+
}
275+
s := e.status
276+
return &s
128277
}
129278

130279
// StagingProgressCallback is called by file stagers to report byte-level progress.

0 commit comments

Comments
 (0)