Files
seaweedfs/weed/mount/filehandle_read.go
T
Chris LuandDevin 6d692ab013 mount: fail writes over to the next filer instead of hanging on a dead one (#11696)
* mount: bound each filer call so a wedged filer cannot hold the failover walk

WithFilerClient callers run their RPCs on context.Background(), so a filer
whose transport stalls without refusing (stopped peer, black-holed
connection) blocks the call forever and the walk never reaches the
remaining filer addresses. Run each attempt under a timeout; on expiry
drop the cached channel so the abandoned call is unblocked and the next
address is tried.

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

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

* mount: time out wedged mutation streams and rotate to the next filer

doUnary and Rename wait on stream Send/Recv under context.Background(); a
dead transport never delivers an error, so writes hang without ever
reaching the unary fallback. Bound the waits and tear the stream down on
timeout; teardown also advances the sticky filer index so the next stream
opens against a different filer instead of redialing the dead one.

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

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

* mount: cover filer failover past a wedged filer

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

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

* mount: cancel filer attempts by context and unwind them before retry

Review follow-ups on the filer failover:

- callFiler derives a per-attempt context instead of only abandoning the
  wait, so a timed-out attempt's RPCs die on their own without tearing
  down the shared cached channel. The walk now also waits for the
  attempt's callback to unwind before trying the next filer, so two runs
  of the same closure cannot race on the caller's captured results.
  Callbacks that ignore the attempt context are still forced out by
  dropping the channel, as a last resort after a grace period.
- teardownStream captures the dying stream's filer index and rotates it
  while holding m.mu, so it can never rotate a successor generation's
  filer by mistake.
- The unary rename fallback bounds silence on its dedicated stream the
  way the mux does, instead of trusting Recv on a wedged filer.
- downloadRemoteEntry opts out of the metadata-call bound: a healthy
  whole-file download can legitimately outlast it.

* mount: count only wire silence against the rename timeout

Applying a rename event can outlast the silence bound on a healthy
filer — a directory rename migrates an open child's DLM lock under
another writer. Stop the mux's response timer for the local work and
re-arm it before the next wait; the fallback stream's watchdog defers
while the handler runs instead of cancelling a live stream.

* mount: restart the rename fallback's silence bound after each event

The bound covers the wait for the next response, not the rename's whole
lifetime — a healthy rename that keeps sending events can outlast it.
Re-arm after each handled event; the defer-while-handling stays for
local work that outlasts the interval on its own.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-11 20:18:22 +08:00

187 lines
6.8 KiB
Go

package mount
import (
"context"
"fmt"
"io"
"google.golang.org/protobuf/proto"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
func (fh *FileHandle) lockForRead(startOffset int64, size int) {
fh.dirtyPages.LockForRead(startOffset, startOffset+int64(size))
}
func (fh *FileHandle) unlockForRead(startOffset int64, size int) {
fh.dirtyPages.UnlockForRead(startOffset, startOffset+int64(size))
}
func (fh *FileHandle) readFromDirtyPages(buff []byte, startOffset int64, tsNs int64) (maxStop int64) {
maxStop = fh.dirtyPages.ReadDirtyDataAt(buff, startOffset, tsNs)
return
}
func (fh *FileHandle) readFromChunks(buff []byte, offset int64) (int64, int64, error) {
return fh.readFromChunksWithContext(context.Background(), buff, offset)
}
func (fh *FileHandle) readFromChunksWithContext(ctx context.Context, buff []byte, offset int64) (int64, int64, error) {
fh.entryLock.RLock()
defer fh.entryLock.RUnlock()
fileFullPath := fh.FullPath()
entry := fh.GetEntry()
// IsInRemoteOnly inspects entry.Chunks, so take the LockedEntry lock the
// async uploader appends under.
entry.RLock()
remoteOnly := entry.Entry.IsInRemoteOnly()
entry.RUnlock()
if remoteOnly {
glog.V(4).Infof("download remote entry %s", fileFullPath)
err := fh.downloadRemoteEntry(ctx, entry)
if err != nil {
glog.V(1).Infof("download remote entry %s: %v", fileFullPath, err)
return 0, 0, err
}
}
// Snapshot size, inline content, and the chunk list under the LockedEntry
// lock. Async upload workers append chunks under this lock (AddChunks), so
// reading entry.Chunks / FileSize without it races with the slice
// reallocation and can crash in filer.TotalSize. The captured slice headers
// stay valid afterwards: append never mutates the old backing array, and
// truncate is excluded by the fh.entryLock held for this whole read.
entry.RLock()
pbEntry := entry.Entry
fileSize := int64(pbEntry.Attributes.FileSize)
if fileSize == 0 {
fileSize = int64(filer.FileSize(pbEntry))
}
content := pbEntry.Content
chunks := pbEntry.Chunks
entry.RUnlock()
if fileSize == 0 {
glog.V(1).Infof("empty fh %v", fileFullPath)
return 0, 0, io.EOF
} else if offset == fileSize {
return 0, 0, io.EOF
} else if offset >= fileSize {
glog.V(1).Infof("invalid read, fileSize %d, offset %d for %s", fileSize, offset, fileFullPath)
return 0, 0, io.EOF
}
if offset < int64(len(content)) {
totalRead := copy(buff, content[offset:])
glog.V(4).Infof("file handle read cached %s [%d,%d] %d", fileFullPath, offset, offset+int64(totalRead), totalRead)
return int64(totalRead), 0, nil
}
// Peer chunk sharing: try a peer mount's cache before the volume tier.
// Any failure falls through transparently. See design-weed-mount-
// peer-chunk-sharing.md §4.3.
if fh.wfs.option.PeerEnabled && fh.wfs.peerGrpcServer != nil {
totalRead, ts, err := fh.tryPeerRead(ctx, fileSize, buff, offset, chunks)
if err == nil {
glog.V(4).Infof("peer read successful for %s [%d,%d] %d", fileFullPath, offset, offset+int64(totalRead), totalRead)
return int64(totalRead), ts, nil
}
// Skip the "failed" log for benign skip reasons (local cache
// hit, no peer owner yet, etc.) — the cache/volume fallback is
// the expected outcome, not a failure.
if err != errPeerReadSkipped {
glog.V(4).Infof("peer read failed for %s, falling back to volume: %v", fileFullPath, err)
}
}
// Fall back to normal chunk reading
totalRead, ts, err := fh.entryChunkGroup.ReadDataAt(ctx, fileSize, buff, offset)
if err != nil && err != io.EOF {
glog.Errorf("file handle read %s: %v", fileFullPath, err)
}
// glog.V(4).Infof("file handle read %s [%d,%d] %d : %v", fileFullPath, offset, offset+int64(totalRead), totalRead, err)
return int64(totalRead), ts, err
}
func (fh *FileHandle) downloadRemoteEntry(ctx context.Context, entry *LockedEntry) error {
fileFullPath := fh.FullPath()
dir, _ := fileFullPath.DirAndName()
// callWait 0: this RPC streams a whole remote object filer-side, so a
// healthy download can legitimately outlast any metadata-call bound. The
// attempt stays bound to the caller's context instead.
err := fh.wfs.withFilerClient(ctx, false, 0, func(ctx context.Context, client filer_pb.SeaweedFilerClient) error {
request := &filer_pb.CacheRemoteObjectToLocalClusterRequest{
Directory: string(dir),
Name: entry.Name,
}
glog.V(4).Infof("download entry: %v", request)
resp, err := client.CacheRemoteObjectToLocalCluster(ctx, request)
if err != nil {
return fmt.Errorf("CacheRemoteObjectToLocalCluster file %s: %v", fileFullPath, err)
}
// Entry and base are kept in local uid/gid form like every other
// install path; the response carries filer ids. Without mapping, an
// unchanged re-delivery compares unequal and destroys dirty pages.
localEntry := proto.Clone(resp.Entry).(*filer_pb.Entry)
if localEntry.Attributes != nil && fh.wfs.option.UidGidMapper != nil {
localEntry.Attributes.Uid, localEntry.Attributes.Gid = fh.wfs.option.UidGidMapper.FilerToLocal(localEntry.Attributes.Uid, localEntry.Attributes.Gid)
}
versionTsNs := ackVersionTsNs(resp)
// Two concurrent reads can both be here (the handle lock is shared;
// invalidation is excluded by the exclusive one). Install as one unit,
// and only if at least as new — an older response landing last would
// keep the newer version over an older entry, fencing out corrections.
installed := false
fh.remoteInstallMu.Lock()
switch {
case versionTsNs >= fh.entryVersionTsNs.Load():
fh.SetEntry(localEntry)
fh.setAuthoritativeBase(proto.Clone(localEntry).(*filer_pb.Entry))
fh.advanceEntryVersion(versionTsNs, resp.GetLogSignature())
installed = true
case versionTsNs == 0 && fh.GetEntry().GetEntry().IsInRemoteOnly():
// Unversioned response (pre-upgrade filer) and the handle still has
// no local chunks to read: take the content, but claim no position.
// A response that is merely *older* is refused instead — its content
// predates what the handle already reflects.
fh.SetEntry(localEntry)
fh.setAuthoritativeBase(proto.Clone(localEntry).(*filer_pb.Entry))
}
fh.remoteInstallMu.Unlock()
// Only publish state we accepted, and only when it carries a position
// to order it by: an unversioned event would clear the cache entry's
// version and let an older subscriber event roll the cache back.
// Async: a sync apply deadlocks against the apply loop's invalidate, which needs this read's file-handle lock.
if installed && versionTsNs != 0 {
event := resp.GetMetadataEvent()
if event == nil {
event = metadataUpdateEvent(request.Directory, resp.Entry)
if event != nil {
event.TsNs = versionTsNs
}
}
fh.wfs.applyLocalMetadataEventAsync(event)
}
return nil
})
return err
}