@@ -3,12 +3,17 @@ package nodes
33import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "fmt"
78 "io"
9+ "net"
810 "net/http"
911 "os"
1012 "path/filepath"
13+ "strconv"
1114 "strings"
15+ "sync"
16+ "syscall"
1217 "time"
1318
1419 "github.com/mudler/LocalAI/core/services/storage"
@@ -19,25 +24,58 @@ import (
1924// Files are transferred between the frontend and backend nodes over a small
2025// HTTP server running alongside the gRPC backend process.
2126type HTTPFileStager struct {
22- httpAddrFor func (nodeID string ) (string , error )
23- token string
24- client * http.Client
27+ httpAddrFor func (nodeID string ) (string , error )
28+ token string
29+ client * http.Client
30+ responseTimeout time.Duration // timeout waiting for server response after upload
31+ maxRetries int // number of retry attempts for transient failures
2532}
2633
2734// NewHTTPFileStager creates a new HTTP file stager.
2835// httpAddrFor should return the HTTP address (host:port) for the given node ID.
2936// token is the registration token used for authentication.
3037func NewHTTPFileStager (httpAddrFor func (nodeID string ) (string , error ), token string ) * HTTPFileStager {
31- timeout := 30 * time .Minute
38+ responseTimeout := 30 * time .Minute
3239 if v := os .Getenv ("LOCALAI_FILE_TRANSFER_TIMEOUT" ); v != "" {
3340 if d , err := time .ParseDuration (v ); err == nil {
34- timeout = d
41+ responseTimeout = d
3542 }
3643 }
44+
45+ maxRetries := 3
46+ if v := os .Getenv ("LOCALAI_FILE_TRANSFER_RETRIES" ); v != "" {
47+ if n , err := strconv .Atoi (v ); err == nil && n >= 0 {
48+ maxRetries = n
49+ }
50+ }
51+
52+ transport := & http.Transport {
53+ DialContext : (& net.Dialer {
54+ Timeout : 30 * time .Second ,
55+ KeepAlive : 15 * time .Second , // aggressive keepalive for LAN transfers
56+ }).DialContext ,
57+ ForceAttemptHTTP2 : false , // HTTP/2 flow control can stall large uploads
58+ MaxIdleConns : 10 ,
59+ IdleConnTimeout : 90 * time .Second ,
60+ TLSHandshakeTimeout : 10 * time .Second ,
61+ ExpectContinueTimeout : 1 * time .Second ,
62+ WriteBufferSize : 256 << 10 , // 256 KB
63+ ReadBufferSize : 256 << 10 , // 256 KB
64+ }
65+
3766 return & HTTPFileStager {
3867 httpAddrFor : httpAddrFor ,
3968 token : token ,
40- client : & http.Client {Timeout : timeout },
69+ client : & http.Client {
70+ // No Timeout set — for large uploads, http.Client.Timeout covers the
71+ // entire request lifecycle including the body upload. If it fires
72+ // mid-write, Go closes the connection causing "connection reset by peer"
73+ // on the server. Instead we use ResponseHeaderTimeout on the transport
74+ // to cover only the wait-for-server-response phase.
75+ Transport : transport ,
76+ },
77+ responseTimeout : responseTimeout ,
78+ maxRetries : maxRetries ,
4179 }
4280}
4381
@@ -49,39 +87,85 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
4987 return "" , fmt .Errorf ("resolving HTTP address for node %s: %w" , nodeID , err )
5088 }
5189
90+ fi , err := os .Stat (localPath )
91+ if err != nil {
92+ return "" , fmt .Errorf ("stat local file %s: %w" , localPath , err )
93+ }
94+ fileSize := fi .Size ()
95+
96+ url := fmt .Sprintf ("http://%s/v1/files/%s" , addr , key )
97+ xlog .Info ("Uploading file to remote node" , "node" , nodeID , "file" , filepath .Base (localPath ), "size" , humanFileSize (fileSize ), "url" , url )
98+
99+ var lastErr error
100+ attempts := h .maxRetries + 1 // maxRetries=3 means 4 total attempts (1 initial + 3 retries)
101+ for attempt := 1 ; attempt <= attempts ; attempt ++ {
102+ if attempt > 1 {
103+ backoff := time .Duration (5 << (attempt - 2 )) * time .Second // 5s, 10s, 20s
104+ xlog .Warn ("Retrying file upload" , "node" , nodeID , "file" , filepath .Base (localPath ),
105+ "attempt" , attempt , "of" , attempts , "backoff" , backoff , "lastError" , lastErr )
106+ select {
107+ case <- ctx .Done ():
108+ return "" , fmt .Errorf ("upload cancelled during retry backoff: %w" , ctx .Err ())
109+ case <- time .After (backoff ):
110+ }
111+ }
112+
113+ result , err := h .doUpload (ctx , addr , nodeID , localPath , key , url , fileSize )
114+ if err == nil {
115+ if attempt > 1 {
116+ xlog .Info ("File upload succeeded after retry" , "node" , nodeID , "file" , filepath .Base (localPath ), "attempt" , attempt )
117+ }
118+ return result , nil
119+ }
120+ lastErr = err
121+
122+ if ! isTransientError (err ) {
123+ xlog .Error ("File upload failed with non-transient error" , "node" , nodeID , "file" , filepath .Base (localPath ), "error" , err )
124+ return "" , err
125+ }
126+ xlog .Warn ("File upload failed with transient error" , "node" , nodeID , "file" , filepath .Base (localPath ),
127+ "attempt" , attempt , "of" , attempts , "error" , err )
128+ }
129+
130+ return "" , fmt .Errorf ("uploading %s to node %s failed after %d attempts: %w" , localPath , nodeID , attempts , lastErr )
131+ }
132+
133+ // doUpload performs a single upload attempt.
134+ func (h * HTTPFileStager ) doUpload (ctx context.Context , addr , nodeID , localPath , key , url string , fileSize int64 ) (string , error ) {
52135 f , err := os .Open (localPath )
53136 if err != nil {
54137 return "" , fmt .Errorf ("opening local file %s: %w" , localPath , err )
55138 }
56139 defer f .Close ()
57140
58- fi , _ := f .Stat ()
59- var fileSize int64
60- if fi != nil {
61- fileSize = fi .Size ()
141+ var body io.Reader = f
142+ // For files > 100MB, wrap with progress logging
143+ const progressThreshold = 100 << 20
144+ if fileSize > progressThreshold {
145+ body = newProgressReader (f , fileSize , filepath .Base (localPath ), nodeID )
62146 }
63147
64- url := fmt .Sprintf ("http://%s/v1/files/%s" , addr , key )
65- xlog .Debug ("HTTP upload starting" , "node" , nodeID , "url" , url , "localPath" , localPath , "fileSize" , fileSize )
66-
67- req , err := http .NewRequestWithContext (ctx , http .MethodPut , url , f )
148+ req , err := http .NewRequestWithContext (ctx , http .MethodPut , url , body )
68149 if err != nil {
69150 return "" , fmt .Errorf ("creating request: %w" , err )
70151 }
152+ req .ContentLength = fileSize // explicit Content-Length for progress tracking
71153 req .Header .Set ("Content-Type" , "application/octet-stream" )
72154 if h .token != "" {
73155 req .Header .Set ("Authorization" , "Bearer " + h .token )
74156 }
75157
76158 resp , err := h .client .Do (req )
77159 if err != nil {
160+ xlog .Error ("File upload failed" , "node" , nodeID , "file" , filepath .Base (localPath ), "size" , humanFileSize (fileSize ), "error" , err )
78161 return "" , fmt .Errorf ("uploading %s to node %s: %w" , localPath , nodeID , err )
79162 }
80163 defer resp .Body .Close ()
81164
82165 if resp .StatusCode != http .StatusOK {
83- body , _ := io .ReadAll (resp .Body )
84- return "" , fmt .Errorf ("upload to node %s failed with status %d: %s" , nodeID , resp .StatusCode , string (body ))
166+ respBody , _ := io .ReadAll (resp .Body )
167+ xlog .Error ("File upload rejected by remote node" , "node" , nodeID , "file" , filepath .Base (localPath ), "status" , resp .StatusCode , "response" , string (respBody ))
168+ return "" , fmt .Errorf ("upload to node %s failed with status %d: %s" , nodeID , resp .StatusCode , string (respBody ))
85169 }
86170
87171 var result struct {
@@ -91,10 +175,113 @@ func (h *HTTPFileStager) EnsureRemote(ctx context.Context, nodeID, localPath, ke
91175 return "" , fmt .Errorf ("decoding upload response: %w" , err )
92176 }
93177
94- xlog .Debug ( "HTTP upload complete" , "node" , nodeID , "remotePath " , result . LocalPath , "fileSize " , fileSize )
178+ xlog .Info ( "File upload complete" , "node" , nodeID , "file " , filepath . Base ( localPath ) , "size " , humanFileSize ( fileSize ), "remotePath" , result . LocalPath )
95179 return result .LocalPath , nil
96180}
97181
182+ // isTransientError returns true if the error is likely transient and worth retrying.
183+ func isTransientError (err error ) bool {
184+ if err == nil {
185+ return false
186+ }
187+ // Connection reset by peer
188+ if errors .Is (err , syscall .ECONNRESET ) {
189+ return true
190+ }
191+ // Broken pipe
192+ if errors .Is (err , syscall .EPIPE ) {
193+ return true
194+ }
195+ // Connection refused (worker might be restarting)
196+ if errors .Is (err , syscall .ECONNREFUSED ) {
197+ return true
198+ }
199+ // Context deadline exceeded (but not cancelled — cancelled means the caller gave up)
200+ if errors .Is (err , context .DeadlineExceeded ) {
201+ return true
202+ }
203+ // net.Error timeout
204+ var netErr net.Error
205+ if errors .As (err , & netErr ) && netErr .Timeout () {
206+ return true
207+ }
208+ // Check for "connection reset" in the error string as a fallback
209+ // (some wrapped errors lose the syscall.Errno)
210+ msg := err .Error ()
211+ if strings .Contains (msg , "connection reset" ) ||
212+ strings .Contains (msg , "broken pipe" ) ||
213+ strings .Contains (msg , "connection refused" ) ||
214+ strings .Contains (msg , "EOF" ) {
215+ return true
216+ }
217+ return false
218+ }
219+
220+ // progressReader wraps an io.Reader and logs upload progress periodically.
221+ type progressReader struct {
222+ reader io.Reader
223+ total int64
224+ read int64
225+ file string
226+ node string
227+ lastLog time.Time
228+ lastPct int
229+ start time.Time
230+ mu sync.Mutex
231+ }
232+
233+ func newProgressReader (r io.Reader , total int64 , file , node string ) * progressReader {
234+ return & progressReader {
235+ reader : r ,
236+ total : total ,
237+ file : file ,
238+ node : node ,
239+ start : time .Now (),
240+ lastLog : time .Now (),
241+ }
242+ }
243+
244+ func (pr * progressReader ) Read (p []byte ) (int , error ) {
245+ n , err := pr .reader .Read (p )
246+ if n > 0 {
247+ pr .mu .Lock ()
248+ pr .read += int64 (n )
249+ pct := int (pr .read * 100 / pr .total )
250+ now := time .Now ()
251+ // Log every 10% or every 30 seconds
252+ if pct / 10 > pr .lastPct / 10 || now .Sub (pr .lastLog ) >= 30 * time .Second {
253+ elapsed := now .Sub (pr .start )
254+ var speed string
255+ if elapsed > 0 {
256+ bytesPerSec := float64 (pr .read ) / elapsed .Seconds ()
257+ speed = humanFileSize (int64 (bytesPerSec )) + "/s"
258+ }
259+ xlog .Info ("Upload progress" , "node" , pr .node , "file" , pr .file ,
260+ "progress" , fmt .Sprintf ("%d%%" , pct ),
261+ "sent" , humanFileSize (pr .read ), "total" , humanFileSize (pr .total ),
262+ "speed" , speed )
263+ pr .lastLog = now
264+ pr .lastPct = pct
265+ }
266+ pr .mu .Unlock ()
267+ }
268+ return n , err
269+ }
270+
271+ // humanFileSize returns a human-readable file size string.
272+ func humanFileSize (b int64 ) string {
273+ const unit = 1024
274+ if b < unit {
275+ return fmt .Sprintf ("%d B" , b )
276+ }
277+ div , exp := int64 (unit ), 0
278+ for n := b / unit ; n >= unit ; n /= unit {
279+ div *= unit
280+ exp ++
281+ }
282+ return fmt .Sprintf ("%.1f %cB" , float64 (b )/ float64 (div ), "KMGTPE" [exp ])
283+ }
284+
98285func (h * HTTPFileStager ) FetchRemote (ctx context.Context , nodeID , remotePath , localDst string ) error {
99286 // For staging files (not under models/ or data/), the worker's file transfer
100287 // server resolves the key as a relative path under its staging directory.
0 commit comments