Files
seaweedfs/weed/mount/weedfs_file_copy_range_test.go
T
Chris LuandClaude Opus 5.5 6ad4b07598 mount: scope and order the final-release writeback fence (#12651)
* mount: fence a dropped handle only when it holds unpublished content

dirtyMetadata is also set by chmod, xattr and fallocate, so a failed
metadata-only close refused every later write of the inode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: fence the inode before its last handle leaves the handle map

A reopen between the map removal and the fence could publish at the
stale kernel EOF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: fence a failed metadata flush only when the handle holds content

A refused synchronous flush or an abandoned writeback metadata flush of a
handle with only chmod, xattr or fallocate changes fenced the inode, so
later writes were refused until FORGET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: count a size change on an open handle as unpublished content

A truncate or extend through an open handle changes the entry's size and
chunks, but it set only dirtyMetadata. A failed close then skipped the
final-release fence, so a reopen before FORGET could publish against the
untruncated entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: mark copied content dirty before each page of a streaming copy

A copy that failed or was cancelled after earlier pages were accepted left
dirtyContent false, so the final-release fence skipped that content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: mark copied content dirty only after a page is accepted

A copy whose first page is rejected leaves the destination unchanged and
the kernel EOF where it was, so fencing the inode on release only blocked
later writes until FORGET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: skip the server-side copy while the destination holds unpublished content

A whole-file server copy clears dirtyContent when it commits, so a page a
cancelled streaming copy left behind would lose its release fence. Fall
back to the streaming copy instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: skip the server-side copy while the source holds unpublished content

The filer copies the stored source, so pages still pending on the source
handle would be missing from the destination. The streaming copy reads
them through the dirty pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: check the publication fence before a server-side whole-file copy

The filer copy publishes the destination without going through the
flush-time guard, so a fenced writeback inode could still take new
content before FORGET. Fall back to the streaming copy, whose flush is
guarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:47:33 -08:00

444 lines
14 KiB
Go

package mount
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func TestWholeFileServerCopyCandidate(t *testing.T) {
wfs := newCopyRangeTestWFS()
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Inode: srcInode,
},
Content: []byte("hello"),
}, 0, 0)
dstHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
Inode: dstInode,
},
}, 0, 0)
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
in := &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
}
copyRequest, ok := wholeFileServerCopyCandidate(srcHandle, dstHandle, in)
if !ok {
t.Fatal("expected whole-file server copy candidate")
}
if copyRequest.srcPath != srcPath {
t.Fatalf("source path = %q, want %q", copyRequest.srcPath, srcPath)
}
if copyRequest.dstPath != dstPath {
t.Fatalf("destination path = %q, want %q", copyRequest.dstPath, dstPath)
}
if copyRequest.sourceSize != 5 {
t.Fatalf("source size = %d, want 5", copyRequest.sourceSize)
}
if copyRequest.srcInode == 0 || copyRequest.dstInode == 0 {
t.Fatalf("expected inode preconditions, got src=%d dst=%d", copyRequest.srcInode, copyRequest.dstInode)
}
srcHandle.dirtyMetadata = true
if _, ok := wholeFileServerCopyCandidate(srcHandle, dstHandle, in); ok {
t.Fatal("dirty source handle should disable whole-file server copy")
}
srcHandle.dirtyMetadata = false
for _, h := range []*FileHandle{srcHandle, dstHandle} {
h.dirtyContent = true
if _, ok := wholeFileServerCopyCandidate(srcHandle, dstHandle, in); ok {
t.Fatal("handle with unpublished content should disable whole-file server copy")
}
h.dirtyContent = false
}
in.Len = 4
if _, ok := wholeFileServerCopyCandidate(srcHandle, dstHandle, in); ok {
t.Fatal("short copy request should disable whole-file server copy")
}
}
func TestCopyFileRangeUsesServerSideWholeFileCopy(t *testing.T) {
wfs := newCopyRangeTestWFS()
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Inode: srcInode,
},
Content: []byte("hello"),
}, 0, 0)
dstHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
Inode: dstInode,
},
}, 0, 0)
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
originalCopy := performServerSideWholeFileCopy
defer func() {
performServerSideWholeFileCopy = originalCopy
}()
var called bool
performServerSideWholeFileCopy = func(cancel <-chan struct{}, gotWFS *WFS, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, entryVersion, serverSideWholeFileCopyOutcome, error) {
called = true
if gotWFS != wfs {
t.Fatalf("wfs = %p, want %p", gotWFS, wfs)
}
if copyRequest.srcPath != srcPath {
t.Fatalf("source path = %q, want %q", copyRequest.srcPath, srcPath)
}
if copyRequest.dstPath != dstPath {
t.Fatalf("destination path = %q, want %q", copyRequest.dstPath, dstPath)
}
return &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Mime: "text/plain; charset=utf-8",
},
Content: []byte("hello"),
}, entryVersion{tsNs: 2000}, serverSideWholeFileCopyCommitted, nil
}
written, status := wfs.CopyFileRange(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
})
if status != fuse.OK {
t.Fatalf("CopyFileRange status = %v, want OK", status)
}
if written != 5 {
t.Fatalf("CopyFileRange wrote %d bytes, want 5", written)
}
if !called {
t.Fatal("expected server-side whole-file copy path to be used")
}
gotEntry := dstHandle.GetEntry().GetEntry()
if gotEntry.Attributes == nil || gotEntry.Attributes.FileSize != 5 {
t.Fatalf("destination size = %v, want 5", gotEntry.GetAttributes().GetFileSize())
}
if string(gotEntry.Content) != "hello" {
t.Fatalf("destination content = %q, want %q", string(gotEntry.Content), "hello")
}
if dstHandle.dirtyMetadata {
t.Fatal("server-side whole-file copy should leave destination handle clean")
}
}
func TestCopyFileRangeDoesNotFallbackAfterCommittedServerCopyRefreshFailure(t *testing.T) {
wfs := newCopyRangeTestWFSWithMetaCache(t)
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Mime: "text/plain; charset=utf-8",
Md5: []byte("abcde"),
Inode: srcInode,
},
Content: []byte("hello"),
}, 0, 0)
dstHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100600,
Inode: dstInode,
},
}, 0, 0)
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
originalCopy := performServerSideWholeFileCopy
defer func() {
performServerSideWholeFileCopy = originalCopy
}()
performServerSideWholeFileCopy = func(cancel <-chan struct{}, gotWFS *WFS, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, entryVersion, serverSideWholeFileCopyOutcome, error) {
if gotWFS != wfs || copyRequest.srcPath != srcPath || copyRequest.dstPath != dstPath {
t.Fatalf("unexpected server-side copy call: wfs=%p src=%q dst=%q", gotWFS, copyRequest.srcPath, copyRequest.dstPath)
}
return nil, entryVersion{}, serverSideWholeFileCopyCommitted, errors.New("reload copied entry: transient filer read failure")
}
written, status := wfs.CopyFileRange(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
})
if status != fuse.OK {
t.Fatalf("CopyFileRange status = %v, want OK", status)
}
if written != 5 {
t.Fatalf("CopyFileRange wrote %d bytes, want 5", written)
}
if dstHandle.dirtyMetadata {
t.Fatal("committed server-side copy should not fall back to dirty-page copy")
}
gotEntry := dstHandle.GetEntry().GetEntry()
if gotEntry.GetAttributes().GetFileSize() != 5 {
t.Fatalf("destination size = %d, want 5", gotEntry.GetAttributes().GetFileSize())
}
if gotEntry.GetAttributes().GetFileMode() != 0100600 {
t.Fatalf("destination mode = %#o, want %#o", gotEntry.GetAttributes().GetFileMode(), uint32(0100600))
}
if string(gotEntry.GetContent()) != "hello" {
t.Fatalf("destination content = %q, want %q", string(gotEntry.GetContent()), "hello")
}
cachedEntry, _, err := wfs.metaCache.FindEntry(context.Background(), dstPath)
if err != nil {
t.Fatalf("metaCache find entry: %v", err)
}
if cachedEntry.FileSize != 5 {
t.Fatalf("metaCache destination size = %d, want 5", cachedEntry.FileSize)
}
if cachedEntry.Mime != "text/plain; charset=utf-8" {
t.Fatalf("metaCache destination mime = %q, want %q", cachedEntry.Mime, "text/plain; charset=utf-8")
}
}
func TestCopyFileRangeReturnsEIOForAmbiguousServerSideCopy(t *testing.T) {
wfs := newCopyRangeTestWFS()
srcPath := util.FullPath("/src.txt")
dstPath := util.FullPath("/dst.txt")
srcInode := wfs.inodeToPath.Lookup(srcPath, 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(dstPath, 1, false, false, 0, true)
srcHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100644,
FileSize: 5,
Inode: srcInode,
},
Content: []byte("hello"),
}, 0, 0)
dstHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{
FileMode: 0100600,
Inode: dstInode,
},
}, 0, 0)
srcHandle.RememberPath(srcPath)
dstHandle.RememberPath(dstPath)
originalCopy := performServerSideWholeFileCopy
defer func() {
performServerSideWholeFileCopy = originalCopy
}()
performServerSideWholeFileCopy = func(cancel <-chan struct{}, gotWFS *WFS, copyRequest wholeFileServerCopyRequest) (*filer_pb.Entry, entryVersion, serverSideWholeFileCopyOutcome, error) {
if gotWFS != wfs || copyRequest.srcPath != srcPath || copyRequest.dstPath != dstPath {
t.Fatalf("unexpected server-side copy call: wfs=%p src=%q dst=%q", gotWFS, copyRequest.srcPath, copyRequest.dstPath)
}
return nil, entryVersion{}, serverSideWholeFileCopyAmbiguous, errors.New("transport timeout after request dispatch")
}
written, status := wfs.CopyFileRange(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 0,
OffOut: 0,
Len: 8,
})
if status != fuse.EIO {
t.Fatalf("CopyFileRange status = %v, want EIO", status)
}
if written != 0 {
t.Fatalf("CopyFileRange wrote %d bytes, want 0", written)
}
if dstHandle.dirtyMetadata {
t.Fatal("ambiguous server-side copy should not fall back to dirty-page copy")
}
if dstHandle.GetEntry().GetEntry().GetAttributes().GetFileSize() != 0 {
t.Fatalf("destination size = %d, want 0", dstHandle.GetEntry().GetEntry().GetAttributes().GetFileSize())
}
}
func newCopyRangeTestWFS() *WFS {
wfs := &WFS{
option: &Option{
ChunkSizeLimit: 1024,
ConcurrentReaders: 1,
VolumeServerAccess: "filerProxy",
FilerAddresses: []pb.ServerAddress{"127.0.0.1:8888"},
},
inodeToPath: NewInodeToPath(util.FullPath("/"), 0),
fhMap: NewFileHandleToInode(),
fhLockTable: util.NewLockTable[FileHandleId](),
hardLinkLockTable: util.NewLockTable[string](),
}
wfs.copyBufferPool.New = func() any {
return make([]byte, 1024)
}
return wfs
}
func newCopyRangeTestWFSWithMetaCache(t *testing.T) *WFS {
t.Helper()
wfs := newCopyRangeTestWFS()
root := util.FullPath("/")
wfs.inodeToPath.MarkChildrenCached(root)
uidGidMapper, err := meta_cache.NewUidGidMapper("", "")
if err != nil {
t.Fatalf("create uid/gid mapper: %v", err)
}
wfs.metaCache = meta_cache.NewMetaCache(
filepath.Join(t.TempDir(), "meta"),
uidGidMapper,
root,
false,
func(path util.FullPath) {
wfs.inodeToPath.MarkChildrenCached(path)
},
func(path util.FullPath) bool {
return wfs.inodeToPath.IsChildrenCached(path)
},
func(meta_cache.EntryInvalidation) {},
nil,
)
t.Cleanup(func() {
wfs.metaCache.Shutdown()
})
return wfs
}
func TestStreamingCopyMarksOnlyAcceptedContentDirty(t *testing.T) {
for _, c := range []struct {
name string
addFails bool
wantOK bool
wantDirty bool
}{
{"accepted", false, true, true},
{"rejected first page", true, false, false},
} {
t.Run(c.name, func(t *testing.T) {
wfs := newCopyRangeTestWFS()
srcInode := wfs.inodeToPath.Lookup(util.FullPath("/src.txt"), 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(util.FullPath("/dst.txt"), 1, false, false, 0, true)
srcHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{FileMode: 0100644, FileSize: 5, Inode: srcInode},
Content: []byte("hello"),
}, 0, 0)
dstHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{FileMode: 0100644, Inode: dstInode},
}, 0, 0)
if c.addFails {
dstHandle.dirtyPages.randomWriter.(*ChunkedDirtyPages).setLastError(errors.New("swap file unavailable"))
}
_, status := wfs.CopyFileRange(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
OffIn: 1,
OffOut: 0,
Len: 4,
})
if (status == fuse.OK) != c.wantOK {
t.Fatalf("CopyFileRange status = %v, want OK = %v", status, c.wantOK)
}
if dstHandle.dirtyContent != c.wantDirty {
t.Fatalf("dirtyContent = %v, want %v", dstHandle.dirtyContent, c.wantDirty)
}
})
}
}
func TestServerSideCopySkipsFencedDestination(t *testing.T) {
wfs := newCopyRangeTestWFS()
wfs.option.WritebackCache = true
srcInode := wfs.inodeToPath.Lookup(util.FullPath("/src.txt"), 1, false, false, 0, true)
dstInode := wfs.inodeToPath.Lookup(util.FullPath("/dst.txt"), 1, false, false, 0, true)
srcHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, srcInode, &filer_pb.Entry{
Name: "src.txt",
Attributes: &filer_pb.FuseAttributes{FileMode: 0100644, FileSize: 5, Inode: srcInode},
Content: []byte("hello"),
}, 0, 0)
dstHandle, _, _, _ := wfs.fhMap.AcquireFileHandle(wfs, dstInode, &filer_pb.Entry{
Name: "dst.txt",
Attributes: &filer_pb.FuseAttributes{FileMode: 0100644, Inode: dstInode},
}, 0, 0)
srcHandle.RememberPath("/src.txt")
dstHandle.RememberPath("/dst.txt")
dstHandle.writebackContentError.Store(true)
originalCopy := performServerSideWholeFileCopy
t.Cleanup(func() { performServerSideWholeFileCopy = originalCopy })
performServerSideWholeFileCopy = func(<-chan struct{}, *WFS, wholeFileServerCopyRequest) (*filer_pb.Entry, entryVersion, serverSideWholeFileCopyOutcome, error) {
t.Fatal("fenced destination must not publish through the filer copy")
return nil, entryVersion{}, serverSideWholeFileCopyCommitted, nil
}
if _, handled, _ := wfs.tryServerSideWholeFileCopy(make(chan struct{}), &fuse.CopyFileRangeIn{
FhIn: uint64(srcHandle.fh),
FhOut: uint64(dstHandle.fh),
Len: 8,
}, srcHandle, dstHandle); handled {
t.Fatal("fenced destination should fall back to the streaming copy")
}
}