Files
seaweedfs/weed/filer/filechunk_manifest.go
T
8db41d0217 [Mount] Cache Chunk Manifest Resolution for Repeated File Opens (#11266)
* cache resolved chunk manifests for Mount

* Address PR review: per-mount cache, singleflight, reuse ResolveOneChunkManifest

- Own the manifest cache per WFS mount instead of a process-global
  variable, so manifests from one filer backend are never served to
  another (Devin/CodeRabbit major bug).
- Coalesce concurrent cold misses via singleflight so only one fetch
  runs during a cold burst (Greptile P2).
- Copy cached data after releasing the mutex so a large copy does not
  block concurrent hits, inserts, and evictions (CodeRabbit nitpick).
- Reuse the existing ResolveOneChunkManifest function name instead of
  introducing a new resolveOneChunkManifest wrapper.
- Validate (unmarshal) manifest bytes before caching so malformed
  manifests do not poison the cache.
- Add TestChunkGroupManifestResolutionCoalescesColdMisses covering
  the singleflight cold-miss path.

* Address round 2 review: coalesced-miss cancellation, test overlap

- Use singleflight.DoChan in fetchOrLoad and select on ctx.Done() so a
  caller whose context is canceled while waiting for an in-flight fetch
  returns ctx.Err() promptly instead of blocking for the leader's
  result (Devin BUG).
- Add TestResolveOneChunkManifestCanceledWaiterReturnsDuringCoalescedMiss
  covering the canceled-waiter path.
- Delay the cold-miss fixture response so the leader's fetch is still
  in flight when concurrent opens join the singleflight, making the
  one-fetch assertions reliable (CodeRabbit Minor).

* Address review: keep ResolveOneChunkManifest four-argument

Restore the exported ResolveOneChunkManifest to its original
four-argument signature so external callers keep compiling. Move the
cache-aware resolution into an unexported resolveOneChunkManifest
helper that accepts the per-mount ChunkManifestCache. The exported
function delegates to the helper with a nil cache, preserving the
historical uncached behavior for every non-Mount caller. The Mount
path (ChunkGroup.SetChunks) now calls the unexported helper with the
mount-owned cache. Tests and benchmarks that exercise the cache path
call the unexported helper directly.

---------

Co-authored-by: Chris Lu <chris.lu@gmail.com>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
2026-09-12 20:06:25 -07:00

579 lines
19 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
})
}
func fetchChunkRange(ctx context.Context, buffer []byte, lookupFileIdFn wdclient.LookupFileIdFunctionType, fileId string, cipherKey []byte, isGzipped bool, offset int64, refreshUrls util_http.RefreshUrlsFunc) (int, error) {
urlStrings, err := lookupFileIdFn(ctx, fileId)
if err != nil {
glog.ErrorfCtx(ctx, "operation LookupFileId %s failed, err: %v", fileId, err)
return 0, err
}
return util_http.RetriedFetchChunkData(ctx, buffer, urlStrings, cipherKey, isGzipped, false, offset, fileId, refreshUrls)
}
// 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)