mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
* http: try a volume server that failed to answer last A cached location list is shuffled on every read, so once a replica dies half the reads keep dialing it first and pay a connect failure or timeout before the healthy replica answers. Remember, per host, when a request got no answer at all and order such hosts last for the next half minute. Once that passes, one read probes the host in its usual place while the others keep it last until the probe settles, so a black-holed server costs one stalled read per interval instead of one per read. Nothing is ever skipped: a host that failed is still tried when the others fail too. Any response, including an error status, counts as reachable. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * filer: refresh a chunk's locations after one of them fails A mount's location cache is only relearned when every cached location fails. When one replica dies and the other still answers, every read succeeds and the dead replica stays in the cache, and in the shuffled order it keeps being dialed first long after the master has dropped it. When a read fails on one location and a later one answers, call the refresh hook so the cached entry is dropped and looked up again. The read that already paid for the failure returns its data; the reads after it start from the locations the master knows now. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * http: claim the probe for every expired host, and try it first The claim was only checked for the first url, so with two replicas whose marks expired together the second was probed by every read at once. Claim each expired host on its own and put the reads that won a claim ahead of the reachable hosts, so a probe is always a real attempt and a lost claim always means the host is tried last. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * filer: refresh a chunk's locations in the streaming read path too The streaming loop had no refresh hook, so a manifest or streamed chunk that failed on one cached location and was served by another kept the stale entry until every location failed. Give it the same hook as the buffered loop, built by one refreshUrls function shared by the reader cache and the stream callers. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * http: probe at most one expired host per read Claiming every expired host in one ordering left all but the first claim without an attempt, since a read stops at its first answer, and a host that had come back waited another interval for nothing. Claim only the first expired host a read sees and leave the rest last and unclaimed, so each following read probes one of them. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs * test: start the live server before releasing the dead server's port Closing the dead server first let the live server come up on the same port, in which case the dead location answers and the partial failure under test never happens. Claude-Session: https://claude.ai/code/session_011NYXuzGttwrTMsfLYvmQFs
257 lines
8.3 KiB
Go
257 lines
8.3 KiB
Go
package filer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/stats"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/mem"
|
|
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
|
)
|
|
|
|
// chunkPipeResult represents a prefetched chunk streaming through a pipe.
|
|
// The fetch goroutine writes data into the pipeWriter; the consumer reads from pipeReader.
|
|
type chunkPipeResult struct {
|
|
chunkView *ChunkView
|
|
reader *io.PipeReader
|
|
fetchErr error // final error from fetch goroutine
|
|
written int64 // bytes written by fetch goroutine
|
|
done chan struct{} // closed when fetch goroutine finishes
|
|
urlStrings []string // snapshot of URLs at dispatch time (for retry logic)
|
|
}
|
|
|
|
// streamChunksPrefetched streams chunks with concurrent prefetch using io.Pipe.
|
|
//
|
|
// For each chunk in file order, a goroutine is launched (bounded by a semaphore)
|
|
// that establishes an HTTP connection to the volume server and streams data through
|
|
// an io.Pipe. The consumer reads from pipes in order, writing to the response.
|
|
//
|
|
// Memory usage is minimal: pipes are synchronous (no buffering), and only one
|
|
// reusable copy buffer is allocated for the consumer.
|
|
func streamChunksPrefetched(
|
|
ctx context.Context,
|
|
writer io.Writer,
|
|
chunkViews *IntervalList[*ChunkView],
|
|
fileId2Url map[string][]string,
|
|
jwtFunc VolumeServerJwtFunction,
|
|
masterClient wdclient.HasLookupFileIdFunction,
|
|
offset int64,
|
|
size int64,
|
|
downloadMaxBytesPs int64,
|
|
prefetchAhead int,
|
|
) error {
|
|
downloadThrottler := util.NewWriteThrottler(downloadMaxBytesPs)
|
|
invalidator, _ := masterClient.(CacheInvalidator)
|
|
|
|
// Create a local cancellable context so the consumer can stop the producer
|
|
// and all in-flight fetch goroutines on error (e.g., client disconnect).
|
|
localCtx, localCancel := context.WithCancel(ctx)
|
|
defer localCancel()
|
|
|
|
// Ordered channel: one entry per chunk, in file order.
|
|
// Capacity = prefetchAhead so the producer can run ahead.
|
|
// Uses pointer to avoid copying the struct while fetch goroutines write to it.
|
|
results := make(chan *chunkPipeResult, prefetchAhead)
|
|
|
|
// Semaphore to limit concurrent fetch goroutines (and thus HTTP connections).
|
|
sem := make(chan struct{}, prefetchAhead)
|
|
|
|
// Producer: walks chunk list, launches fetch goroutines, sends results in order.
|
|
// The producer only reads from fileId2Url (populated before streaming starts),
|
|
// so there is no concurrent map access — the consumer never writes to it.
|
|
var producerWg sync.WaitGroup
|
|
producerWg.Add(1)
|
|
go func() {
|
|
defer producerWg.Done()
|
|
defer close(results)
|
|
|
|
for x := chunkViews.Front(); x != nil; x = x.Next {
|
|
chunkView := x.Value
|
|
|
|
// Check context before starting new fetch
|
|
select {
|
|
case <-localCtx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
// Acquire semaphore slot (bounds concurrent HTTP connections)
|
|
select {
|
|
case sem <- struct{}{}:
|
|
case <-localCtx.Done():
|
|
return
|
|
}
|
|
|
|
pr, pw := io.Pipe()
|
|
urlStrings := fileId2Url[chunkView.FileId]
|
|
jwt := jwtFunc(chunkView.FileId)
|
|
|
|
result := &chunkPipeResult{
|
|
chunkView: chunkView,
|
|
reader: pr,
|
|
done: make(chan struct{}),
|
|
urlStrings: urlStrings,
|
|
}
|
|
|
|
// Launch fetch goroutine
|
|
go func(cv *ChunkView, urls []string, jwt string, pw *io.PipeWriter, res *chunkPipeResult, refresh util_http.RefreshUrlsFunc) {
|
|
defer func() { <-sem }() // release semaphore
|
|
defer close(res.done)
|
|
|
|
written, err := retriedStreamFetchChunkData(
|
|
localCtx, pw, urls, jwt,
|
|
cv.CipherKey, cv.IsGzipped, cv.IsFullChunk(),
|
|
cv.OffsetInChunk, int(cv.ViewSize), refresh,
|
|
)
|
|
res.written = written
|
|
res.fetchErr = err
|
|
|
|
if err != nil {
|
|
pw.CloseWithError(err)
|
|
} else {
|
|
pw.Close()
|
|
}
|
|
}(chunkView, urlStrings, jwt, pw, result, refreshUrls(localCtx, invalidator, masterClient.GetLookupFileIdFunction(), chunkView.FileId))
|
|
|
|
// Send result to consumer (blocks if channel full, back-pressuring producer)
|
|
select {
|
|
case results <- result:
|
|
case <-localCtx.Done():
|
|
// Consumer gone; close the pipe and wait for the fetch goroutine
|
|
// to finish so we don't leak it (this result was never sent to
|
|
// the channel, so the drain loop won't handle it).
|
|
pr.Close()
|
|
<-result.done
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Consumer: reads from results channel in order, writes to response writer.
|
|
// Use the SeaweedFS memory pool for the copy buffer to reduce GC pressure.
|
|
copyBuf := mem.Allocate(256 * 1024)
|
|
defer mem.Free(copyBuf)
|
|
remaining := size
|
|
|
|
var consumeErr error
|
|
for result := range results {
|
|
chunkView := result.chunkView
|
|
|
|
// Handle gap before this chunk (zero-fill)
|
|
if offset < chunkView.ViewOffset {
|
|
gap := chunkView.ViewOffset - offset
|
|
remaining -= gap
|
|
glog.V(4).InfofCtx(ctx, "prefetch zero [%d,%d)", offset, chunkView.ViewOffset)
|
|
if err := writeZero(writer, gap); err != nil {
|
|
consumeErr = fmt.Errorf("write zero [%d,%d): %w", offset, chunkView.ViewOffset, err)
|
|
result.reader.Close()
|
|
break
|
|
}
|
|
offset = chunkView.ViewOffset
|
|
}
|
|
|
|
// Stream chunk data from pipe to response
|
|
start := time.Now()
|
|
_, copyErr := io.CopyBuffer(writer, result.reader, copyBuf)
|
|
result.reader.Close()
|
|
|
|
// Wait for fetch goroutine to finish to get final error
|
|
<-result.done
|
|
|
|
// Determine the effective error
|
|
err := copyErr
|
|
if err == nil && result.fetchErr != nil && result.written == 0 {
|
|
err = result.fetchErr
|
|
}
|
|
|
|
// If the fetcher itself failed before writing any data, try cache invalidation
|
|
// + re-fetch (same as sequential path stream.go:197). We check result.fetchErr
|
|
// and result.written (not copied) to avoid wrongly retrying when the fetch
|
|
// succeeded but the response writer failed on the first write.
|
|
if result.fetchErr != nil && result.written == 0 {
|
|
if err := localCtx.Err(); err != nil {
|
|
consumeErr = err
|
|
break
|
|
}
|
|
retryErr := retryWithCacheInvalidation(localCtx, writer, chunkView, result.urlStrings, result.fetchErr, jwtFunc, masterClient)
|
|
if retryErr != nil {
|
|
stats.FilerHandlerCounter.WithLabelValues("chunkDownloadError").Inc()
|
|
consumeErr = fmt.Errorf("read chunk: %w", retryErr)
|
|
break
|
|
}
|
|
// Retry succeeded
|
|
err = nil
|
|
} else if err != nil {
|
|
if localCtx.Err() != nil {
|
|
consumeErr = localCtx.Err()
|
|
} else {
|
|
stats.FilerHandlerCounter.WithLabelValues("chunkDownloadError").Inc()
|
|
consumeErr = fmt.Errorf("read chunk: %w", err)
|
|
}
|
|
break
|
|
}
|
|
|
|
offset += int64(chunkView.ViewSize)
|
|
remaining -= int64(chunkView.ViewSize)
|
|
stats.FilerRequestHistogram.WithLabelValues("chunkDownload").Observe(time.Since(start).Seconds())
|
|
stats.FilerHandlerCounter.WithLabelValues("chunkDownload").Inc()
|
|
downloadThrottler.MaybeSlowdown(int64(chunkView.ViewSize))
|
|
}
|
|
|
|
// Cancel the local context to stop the producer and any in-flight fetchers early.
|
|
// This ensures goroutines don't linger after the consumer exits (e.g., on write error).
|
|
localCancel()
|
|
|
|
// Drain remaining results to close pipes and unblock fetch goroutines
|
|
for result := range results {
|
|
result.reader.Close()
|
|
<-result.done
|
|
}
|
|
|
|
// Wait for producer to finish
|
|
producerWg.Wait()
|
|
|
|
if consumeErr != nil {
|
|
return consumeErr
|
|
}
|
|
|
|
// Handle trailing zero-fill
|
|
if remaining > 0 {
|
|
glog.V(4).InfofCtx(ctx, "prefetch zero [%d,%d)", offset, offset+remaining)
|
|
if err := writeZero(writer, remaining); err != nil {
|
|
return fmt.Errorf("write zero [%d,%d): %w", offset, offset+remaining, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// retryWithCacheInvalidation re-fetches a chunk via the shared location self-heal after the
|
|
// initial fetch failed with originalErr.
|
|
func retryWithCacheInvalidation(
|
|
ctx context.Context,
|
|
writer io.Writer,
|
|
chunkView *ChunkView,
|
|
oldUrlStrings []string,
|
|
originalErr error,
|
|
jwtFunc VolumeServerJwtFunction,
|
|
masterClient wdclient.HasLookupFileIdFunction,
|
|
) error {
|
|
invalidator, _ := masterClient.(CacheInvalidator)
|
|
return retryFetchWithFreshLocations(ctx, invalidator, masterClient.GetLookupFileIdFunction(), chunkView.FileId, oldUrlStrings, originalErr, func(newUrls []string) error {
|
|
jwt := jwtFunc(chunkView.FileId)
|
|
_, err := retriedStreamFetchChunkData(
|
|
ctx, writer, newUrls, jwt,
|
|
chunkView.CipherKey, chunkView.IsGzipped, chunkView.IsFullChunk(),
|
|
chunkView.OffsetInChunk, int(chunkView.ViewSize), nil,
|
|
)
|
|
return err
|
|
})
|
|
}
|