@@ -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.
1113type 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.
2757type 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.
3373func 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.
40110func (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.
53132func (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.
85181func (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).
101206func (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.
108248func (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).
119265func (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