Files
seaweedfs/weed/filer/filechunk_manifest.go
T
Chris LuandDevin 6c07a5fdd0 s3: keep small ranged GETs on range reads, no whole-chunk downloads (#11577)
* filer: keep a ranged read in random mode through its contiguous tail

A far ReadAt on a fresh ReaderPattern left the sequential counter at -1,
so the next buffer of the same ranged request landed on the frontier and
flipped the verdict straight back to sequential — readChunkSliceAt then
paid a whole-chunk fetch for the remainder of the range. Drop the
counter to -ModeChangeLimit when random mode is entered so the verdict
needs sustained sequential evidence to undo, matching the hysteresis an
established sequential stream already gets.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: pin small ranged GETs to range reads

A ranged GET whose first read lands within SeqTolerance of offset 0 is
judged sequential immediately, and even a far-starting range could flip
back mid-request; either way readChunkSliceAt downloads each covered
chunk in full, multiplying disk reads for small ranged reads (measured
~7x). Pin random mode for ranged requests no larger than SeqTolerance so
all of the request's buffer reads stay range fetches. Larger ranges keep
the dynamic pattern, where whole-chunk fetches amortize.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* filer: fetch only the part of a chunk the view covers

Replaces the PinRandomMode size heuristic with a per-chunk coverage rule.
ViewFromVisibleIntervals already clips chunk views to the request window,
so a view that is not IsFullChunk() is one the request only partially
needs; fetch it as a range regardless of the detected read pattern.

This closes the holes a request-size pin left open: ranges larger than
SeqTolerance no longer revert to whole-chunk downloads once their buffers
look sequential, and ranges that fully cover a chunk keep the shared
whole-chunk path instead of fetching 256KiB slices piecemeal. Prefetch
(MaybeCache) skips clipped views so it cannot amplify a range read either.

PinRandomMode is dropped: no caller needs it once coverage drives the
fetch choice. Range fetches route through fetchChunkDataFn so tests
observe them the same way as whole-chunk downloads.

* filer: keep ciphered chunks on the whole-chunk path

A range fetch cannot save bytes for a ciphered chunk: readEncryptedUrl
always downloads and decrypts the whole blob before slicing. Sending
partial views of ciphered chunks through fetchChunkRange would repeat the
full download per buffer, so they keep the shared whole-chunk path where
one download serves every buffer. Prefetch stays enabled for them for
the same reason.

* filer: keep compressed chunks on the whole-chunk path

Like ciphered chunks, a range request on a compressed chunk makes the
volume server read and decompress the whole needle, so range-per-buffer
would repeat the full backend read for each 256KiB window. Route them
through the shared whole-chunk path via ChunkView.CanRangeFetch.

* filer: fall back to range fetch when a chunk exceeds the reader budget

A ciphered or compressed chunk larger than readerCacheSizeMB can never
be read through the whole-chunk path — the budget rejects the buffer —
so its partial views must still range-fetch or the GET fails outright.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-03 15:01:04 +08:00

570 lines
18 KiB
Go

package filer
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"math"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
var bytesBufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
// Keep Manifest reads bounded across all recursion levels of one resolution.
const maxChunkManifestResolveWorkers = 4
// Size of the job queue buffer. Large enough that submission does not block
// under normal chunk counts, so a promptly-failing manifest is always queued
// and can cancel stalled sibling reads once a worker picks it up.
const chunkManifestResolveJobBufferSize = 128
func HasChunkManifest(chunks []*filer_pb.FileChunk) bool {
for _, chunk := range chunks {
if chunk.IsChunkManifest {
return true
}
}
return false
}
func SeparateManifestChunks(chunks []*filer_pb.FileChunk) (manifestChunks, nonManifestChunks []*filer_pb.FileChunk) {
for _, c := range chunks {
if c.IsChunkManifest {
manifestChunks = append(manifestChunks, c)
} else {
nonManifestChunks = append(nonManifestChunks, c)
}
}
return
}
func ResolveChunkManifest(ctx context.Context, lookupFileIdFn wdclient.LookupFileIdFunctionType, chunks []*filer_pb.FileChunk, startOffset, stopOffset int64, invalidator CacheInvalidator) (dataChunks, manifestChunks []*filer_pb.FileChunk, manifestResolveErr error) {
resolver := newChunkManifestResolver(ctx, lookupFileIdFn, invalidator)
defer resolver.close()
return resolver.resolve(chunks, startOffset, stopOffset)
}
type chunkManifestResolveJob struct {
chunk *filer_pb.FileChunk
result *chunkManifestResolveResult
done *sync.WaitGroup
batchCtx context.Context
batchCancel context.CancelFunc
batchOnce *sync.Once
}
type chunkManifestResolveResult struct {
chunks []*filer_pb.FileChunk
err error
internalCancel bool
}
type chunkManifestResolver struct {
ctx context.Context
parentCtx context.Context
cancel context.CancelFunc
lookupFileIdFn wdclient.LookupFileIdFunctionType
invalidator CacheInvalidator
jobs chan chunkManifestResolveJob
overflowSem chan struct{}
workers sync.WaitGroup
startOnce sync.Once
started bool
}
func newChunkManifestResolver(ctx context.Context, lookupFileIdFn wdclient.LookupFileIdFunctionType, invalidator CacheInvalidator) *chunkManifestResolver {
workCtx, cancel := context.WithCancel(ctx)
resolver := &chunkManifestResolver{
ctx: workCtx,
parentCtx: ctx,
cancel: cancel,
lookupFileIdFn: lookupFileIdFn,
invalidator: invalidator,
jobs: make(chan chunkManifestResolveJob, chunkManifestResolveJobBufferSize),
overflowSem: make(chan struct{}, maxChunkManifestResolveWorkers),
}
return resolver
}
func (r *chunkManifestResolver) executeJob(job chunkManifestResolveJob) {
job.result.chunks, job.result.err = ResolveOneChunkManifest(job.batchCtx, r.lookupFileIdFn, job.chunk, r.invalidator)
if job.result.err != nil && r.parentCtx.Err() == nil {
if job.batchCtx.Err() != nil && errors.Is(job.result.err, context.Canceled) {
job.result.internalCancel = true
} else if job.batchCtx.Err() == nil {
job.batchOnce.Do(job.batchCancel)
}
}
job.done.Done()
}
func (r *chunkManifestResolver) executeOverflowJob(job chunkManifestResolveJob) {
select {
case r.overflowSem <- struct{}{}:
defer func() { <-r.overflowSem }()
r.executeJob(job)
case <-job.batchCtx.Done():
if job.result.err == nil {
job.result.err = job.batchCtx.Err()
job.result.internalCancel = r.parentCtx.Err() == nil && job.result.err != nil
}
job.done.Done()
case <-r.ctx.Done():
if job.result.err == nil {
job.result.err = r.ctx.Err()
job.result.internalCancel = r.parentCtx.Err() == nil && job.result.err != nil
}
job.done.Done()
}
}
func (r *chunkManifestResolver) worker() {
defer r.workers.Done()
for job := range r.jobs {
r.executeJob(job)
}
}
func (r *chunkManifestResolver) close() {
r.cancel()
close(r.jobs)
if r.started {
r.workers.Wait()
}
}
func (r *chunkManifestResolver) submit(job chunkManifestResolveJob) bool {
r.startOnce.Do(func() {
r.workers.Add(maxChunkManifestResolveWorkers)
for i := 0; i < maxChunkManifestResolveWorkers; i++ {
go r.worker()
}
r.started = true
})
select {
case r.jobs <- job:
return true
case <-r.ctx.Done():
return false
case <-job.batchCtx.Done():
// Batch was cancelled by a sibling failure; don't queue this job.
// Set the result so the caller doesn't overwrite it; the caller
// owns the WaitGroup decrement.
job.result.err = job.batchCtx.Err()
job.result.internalCancel = r.parentCtx.Err() == nil && job.result.err != nil
return false
default:
// Buffer is full (exceedingly rare: more than chunkManifestResolveJobBufferSize
// in-range manifests at one level). Run the job in a bounded overflow goroutine
// so a promptly-failing manifest can still cancel the batch without waiting for
// a worker slot, while keeping total concurrency bounded.
go r.executeOverflowJob(job)
return true
}
}
func (r *chunkManifestResolver) resolve(chunks []*filer_pb.FileChunk, startOffset, stopOffset int64) (dataChunks, manifestChunks []*filer_pb.FileChunk, manifestResolveErr error) {
type resolveSlot struct {
chunk *filer_pb.FileChunk
result chunkManifestResolveResult
}
// Cancellation is scoped to this parallel read batch so a failure in a
// later manifest does not cancel recursive work for an earlier manifest
// that already completed. Recursion creates its own batch context derived
// from the still-alive resolver context.
batchCtx, batchCancel := context.WithCancel(r.ctx)
defer batchCancel()
var batchOnce sync.Once
slots := make([]resolveSlot, len(chunks))
var reads sync.WaitGroup
for i, chunk := range chunks {
if max(chunk.Offset, startOffset) >= min(chunk.Offset+int64(chunk.Size), stopOffset) {
continue
}
slots[i].chunk = chunk
if !chunk.IsChunkManifest {
continue
}
reads.Add(1)
if !r.submit(chunkManifestResolveJob{
chunk: chunk,
result: &slots[i].result,
done: &reads,
batchCtx: batchCtx,
batchCancel: batchCancel,
batchOnce: &batchOnce,
}) {
// submit may have already set the result (batch cancellation).
// Only set it here for the resolver-cancellation case.
if slots[i].result.err == nil {
slots[i].result.err = r.ctx.Err()
slots[i].result.internalCancel = r.parentCtx.Err() == nil && slots[i].result.err != nil
}
reads.Done()
}
}
reads.Wait()
if err := r.parentCtx.Err(); err != nil {
return dataChunks, nil, err
}
// Recurse only after this level's reads release their worker slots. A
// worker must never wait for a child manifest while holding a slot.
//
// Pre-scan for the first real (non-internal-cancel) error before
// recursing. If a later manifest already failed, return its error
// promptly with data chunks that are already in hand, instead of
// blocking on recursive reads of earlier manifests' children.
for i, slot := range slots {
if slot.chunk == nil {
continue
}
if slot.result.err != nil {
if slot.result.internalCancel {
continue
}
for j := 0; j < i; j++ {
if slots[j].chunk == nil {
continue
}
if !slots[j].chunk.IsChunkManifest {
dataChunks = append(dataChunks, slots[j].chunk)
continue
}
for _, c := range slots[j].result.chunks {
if !c.IsChunkManifest && max(c.Offset, startOffset) < min(c.Offset+int64(c.Size), stopOffset) {
dataChunks = append(dataChunks, c)
}
}
}
return dataChunks, nil, slot.result.err
}
}
for _, slot := range slots {
if slot.chunk == nil {
continue
}
if slot.result.err != nil {
if slot.result.internalCancel {
continue
}
return dataChunks, nil, slot.result.err
}
if !slot.chunk.IsChunkManifest {
dataChunks = append(dataChunks, slot.chunk)
continue
}
manifestChunks = append(manifestChunks, slot.chunk)
subDataChunks, subManifestChunks, subErr := r.resolve(slot.result.chunks, startOffset, stopOffset)
if subErr != nil {
return dataChunks, nil, subErr
}
dataChunks = append(dataChunks, subDataChunks...)
manifestChunks = append(manifestChunks, subManifestChunks...)
}
return
}
// ResolveOneChunkManifest fetches and decodes a single manifest chunk. It is
// the uncached, exported path used by every non-Mount caller; the Mount path
// routes through resolveOneChunkManifest so it can share a per-mount cache.
// Keeping this signature stable preserves the existing four-argument contract
// for external callers.
func ResolveOneChunkManifest(ctx context.Context, lookupFileIdFn wdclient.LookupFileIdFunctionType, chunk *filer_pb.FileChunk, invalidator CacheInvalidator) (dataChunks []*filer_pb.FileChunk, manifestResolveErr error) {
return resolveOneChunkManifest(ctx, lookupFileIdFn, chunk, invalidator, nil)
}
// resolveOneChunkManifest is the cache-aware implementation. cache may be nil,
// in which case the manifest is fetched and validated on every call, matching
// the historical uncached behavior. A non-nil cache is owned by a single mount
// (WFS) and coalesces concurrent cold misses via singleflight.
func resolveOneChunkManifest(ctx context.Context, lookupFileIdFn wdclient.LookupFileIdFunctionType, chunk *filer_pb.FileChunk, invalidator CacheInvalidator, cache *ChunkManifestCache) (dataChunks []*filer_pb.FileChunk, manifestResolveErr error) {
if !chunk.IsChunkManifest {
return
}
if err := ctx.Err(); err != nil {
return nil, err
}
key := chunkManifestCacheKey{
fileID: chunk.GetFileIdString(),
cipherKey: string(chunk.CipherKey),
isCompressed: chunk.IsCompressed,
}
fetch := func() ([]byte, error) {
bytesBuffer := bytesBufferPool.Get().(*bytes.Buffer)
bytesBuffer.Reset()
defer bytesBufferPool.Put(bytesBuffer)
if err := fetchWholeChunk(ctx, bytesBuffer, lookupFileIdFn, key.fileID, chunk.CipherKey, chunk.IsCompressed, invalidator); err != nil {
return nil, fmt.Errorf("fail to read manifest %s: %w", key.fileID, err)
}
// Copy before the buffer returns to the pool so concurrent callers
// cannot overwrite the slice before it is cached.
data := append([]byte(nil), bytesBuffer.Bytes()...)
// Validate before returning so fetchOrLoad only caches well-formed
// manifests. A malformed manifest must not poison the cache.
if err := proto.Unmarshal(data, &filer_pb.FileChunkManifest{}); err != nil {
return nil, fmt.Errorf("fail to unmarshal manifest %s: %w", key.fileID, err)
}
return data, nil
}
var manifestBytes []byte
if cache != nil {
data, err := cache.fetchOrLoad(ctx, key, fetch)
if err != nil {
return nil, err
}
manifestBytes = data
} else {
data, err := fetch()
if err != nil {
return nil, err
}
manifestBytes = data
}
m := &filer_pb.FileChunkManifest{}
if err := proto.Unmarshal(manifestBytes, m); err != nil {
return nil, fmt.Errorf("fail to unmarshal manifest %s: %w", key.fileID, err)
}
// recursive
filer_pb.AfterEntryDeserialization(m.Chunks)
return m.Chunks, nil
}
func fetchWholeChunk(ctx context.Context, bytesBuffer *bytes.Buffer, lookupFileIdFn wdclient.LookupFileIdFunctionType, fileId string, cipherKey []byte, isGzipped bool, invalidator CacheInvalidator) error {
urlStrings, err := lookupFileIdFn(ctx, fileId)
if err != nil {
glog.ErrorfCtx(ctx, "operation LookupFileId %s failed, err: %v", fileId, err)
return err
}
jwt := ChunkReadJwt(urlStrings, fileId)
if _, err = retriedStreamFetchChunkData(ctx, bytesBuffer, urlStrings, jwt, cipherKey, isGzipped, true, 0, 0, refreshUrls(ctx, invalidator, lookupFileIdFn, fileId)); err == nil {
return nil
}
if ctxErr := ctx.Err(); ctxErr != nil {
// a cancelled read says nothing about where the volume lives, and the
// stream error it provoked is a symptom, not the cause
return ctxErr
}
return retryFetchWithFreshLocations(ctx, invalidator, lookupFileIdFn, fileId, urlStrings, err, func(newUrls []string) error {
// the failed attempt may have streamed a partial prefix into the buffer
bytesBuffer.Reset()
_, retryErr := retriedStreamFetchChunkData(ctx, bytesBuffer, newUrls, jwt, cipherKey, isGzipped, true, 0, 0, nil)
return retryErr
})
}
// retriedStreamFetchChunkData streams a chunk from the first location that
// answers. refreshUrls may be nil; when a location failed and a later one
// answered, it is called so the reads that follow start from a fresh list.
func retriedStreamFetchChunkData(ctx context.Context, writer io.Writer, urlStrings []string, jwt string, cipherKey []byte, isGzipped bool, isFullChunk bool, offset int64, size int, refreshUrls util_http.RefreshUrlsFunc) (written int64, err error) {
var shouldRetry bool
var totalWritten int
for waitTime := time.Second; waitTime < util.RetryWaitTime; waitTime += waitTime / 2 {
// Check for context cancellation before starting retry loop
select {
case <-ctx.Done():
return int64(totalWritten), ctx.Err()
default:
}
retriedCnt := 0
var failed bool
for _, urlString := range util_http.ReachableFirst(urlStrings) {
// Check for context cancellation before each volume server request
select {
case <-ctx.Done():
return int64(totalWritten), ctx.Err()
default:
}
retriedCnt++
var localProcessed int
var writeErr error
shouldRetry, err = util_http.ReadUrlAsStream(ctx, util_http.AppendQueryParameter(urlString, "readDeleted", "true"), jwt, cipherKey, isGzipped, isFullChunk, offset, size, func(data []byte) {
// Check for context cancellation during data processing
select {
case <-ctx.Done():
writeErr = ctx.Err()
return
default:
}
if totalWritten > localProcessed {
toBeSkipped := totalWritten - localProcessed
if len(data) <= toBeSkipped {
localProcessed += len(data)
return // skip if already processed
}
data = data[toBeSkipped:]
localProcessed += toBeSkipped
}
var writtenCount int
writtenCount, writeErr = writer.Write(data)
localProcessed += writtenCount
totalWritten += writtenCount
})
if !shouldRetry {
break
}
if writeErr != nil {
err = writeErr
break
}
if err != nil {
failed = true
glog.V(0).InfofCtx(ctx, "read %s failed, err: %v", urlString, err)
} else {
break
}
}
if err == nil && failed && refreshUrls != nil {
refreshUrls()
}
// all nodes have tried it
if retriedCnt == len(urlStrings) {
break
}
if err != nil && shouldRetry {
glog.V(0).InfofCtx(ctx, "retry reading in %v", waitTime)
// Sleep with proper context cancellation and timer cleanup
timer := time.NewTimer(waitTime)
select {
case <-ctx.Done():
timer.Stop()
return int64(totalWritten), ctx.Err()
case <-timer.C:
// Continue with retry
}
} else {
break
}
}
return int64(totalWritten), err
}
// MaybeManifestize folds a flat chunk list into manifest chunks once it passes
// ManifestBatch, so an entry's chunk list stays within what the metadata store
// will hold. A fold that fails partway returns inputChunks unchanged -- never
// the half-folded list, which drops the manifests the caller came in with --
// and hands the blobs it had already saved to deleteChunks, since the flat list
// it returns references none of them. deleteChunks may be nil where the caller
// has no deleter to offer; then the blobs are only named in the log.
func MaybeManifestize(saveFunc SaveDataAsChunkFunctionType, deleteChunks func([]*filer_pb.FileChunk), inputChunks []*filer_pb.FileChunk) (chunks []*filer_pb.FileChunk, err error) {
var saved []*filer_pb.FileChunk
record := func(reader io.Reader, name string, offset int64, tsNs int64, expectedDataSize uint64) (*filer_pb.FileChunk, error) {
chunk, saveErr := saveFunc(reader, name, offset, tsNs, expectedDataSize)
if saveErr == nil {
saved = append(saved, chunk)
}
return chunk, saveErr
}
chunks, err = doMaybeManifestize(record, inputChunks, ManifestBatch, mergeIntoManifest)
if err == nil {
return chunks, nil
}
if len(saved) > 0 {
if deleteChunks != nil {
deleteChunks(saved)
} else {
glog.V(0).Infof("manifestize failed, %d manifest blobs left unreferenced: %v", len(saved), err)
}
}
return inputChunks, err
}
func doMaybeManifestize(saveFunc SaveDataAsChunkFunctionType, inputChunks []*filer_pb.FileChunk, mergeFactor int, mergefn func(saveFunc SaveDataAsChunkFunctionType, dataChunks []*filer_pb.FileChunk) (manifestChunk *filer_pb.FileChunk, err error)) (chunks []*filer_pb.FileChunk, err error) {
var dataChunks []*filer_pb.FileChunk
for _, chunk := range inputChunks {
if !chunk.IsChunkManifest {
dataChunks = append(dataChunks, chunk)
} else {
chunks = append(chunks, chunk)
}
}
remaining := len(dataChunks)
for i := 0; i+mergeFactor <= len(dataChunks); i += mergeFactor {
chunk, err := mergefn(saveFunc, dataChunks[i:i+mergeFactor])
if err != nil {
// dataChunks is what is left after the manifests the caller
// already had were separated out; returning it would drop them
return inputChunks, err
}
chunks = append(chunks, chunk)
remaining -= mergeFactor
}
// remaining
for i := len(dataChunks) - remaining; i < len(dataChunks); i++ {
chunks = append(chunks, dataChunks[i])
}
return
}
func mergeIntoManifest(saveFunc SaveDataAsChunkFunctionType, dataChunks []*filer_pb.FileChunk) (manifestChunk *filer_pb.FileChunk, err error) {
filer_pb.BeforeEntrySerialization(dataChunks)
// create and serialize the manifest
data, serErr := proto.Marshal(&filer_pb.FileChunkManifest{
Chunks: dataChunks,
})
if serErr != nil {
return nil, fmt.Errorf("serializing manifest: %w", serErr)
}
minOffset, maxOffset := int64(math.MaxInt64), int64(math.MinInt64)
for _, chunk := range dataChunks {
if minOffset > int64(chunk.Offset) {
minOffset = chunk.Offset
}
if maxOffset < int64(chunk.Size)+chunk.Offset {
maxOffset = int64(chunk.Size) + chunk.Offset
}
}
manifestChunk, err = saveFunc(bytes.NewReader(data), "", 0, 0, uint64(len(data)))
if err != nil {
return nil, err
}
manifestChunk.IsChunkManifest = true
manifestChunk.Offset = minOffset
manifestChunk.Size = uint64(maxOffset - minOffset)
return
}
type SaveDataAsChunkFunctionType func(reader io.Reader, name string, offset int64, tsNs int64, expectedDataSize uint64) (chunk *filer_pb.FileChunk, err error)