diff --git a/weed/mount/weedfs_attr.go b/weed/mount/weedfs_attr.go index c4d2eb539..3922201bb 100644 --- a/weed/mount/weedfs_attr.go +++ b/weed/mount/weedfs_attr.go @@ -103,7 +103,8 @@ func (wfs *WFS) SetAttr(cancel <-chan struct{}, input *fuse.SetAttrIn, out *fuse // Invalidate the open-mtime cache so the next Open does not set // FOPEN_KEEP_CACHE with stale kernel page cache data. wfs.invalidateOpenMtimeCache(input.NodeId) - if size < filer.FileSize(entry) { + oldFileSize := filer.FileSize(entry) + if size < oldFileSize { // fmt.Printf("truncate %v \n", fullPath) var chunks []*filer_pb.FileChunk var truncatedChunks []*filer_pb.FileChunk @@ -134,6 +135,12 @@ func (wfs *WFS) SetAttr(cancel <-chan struct{}, input *fuse.SetAttrIn, out *fuse entry.Attributes.Mtime = truncNow.Unix() entry.Attributes.MtimeNs = int32(truncNow.Nanosecond()) entry.Attributes.FileSize = size + if size > oldFileSize { + // The writes that fill the range will not grow the file, so they + // charge nothing; the growth is counted here or the quota never + // sees it. Matches Write and Fallocate. + wfs.AddUncommittedBytes(int64(size - oldFileSize)) + } } diff --git a/weed/mount/weedfs_attr_quota_test.go b/weed/mount/weedfs_attr_quota_test.go new file mode 100644 index 000000000..53d964962 --- /dev/null +++ b/weed/mount/weedfs_attr_quota_test.go @@ -0,0 +1,38 @@ +package mount + +import ( + "sync/atomic" + "testing" + + "github.com/seaweedfs/go-fuse/v2/fuse" +) + +// TestSetAttrChargesTheGrowth pins the quota accounting for a truncate up: the +// writes that fill the range do not grow the file, so they charge nothing and +// the growth has to be counted where it happens, as Write and Fallocate do. +func TestSetAttrChargesTheGrowth(t *testing.T) { + atomic.StoreInt64(&uncommittedBytes, 0) + wfs, fh := newOpenFileHandle(t, 10) + wfs.option.Quota = 100 << 20 + + in := &fuse.SetAttrIn{} + in.NodeId = fh.inode + in.Valid = fuse.FATTR_SIZE + in.Size = 8192 + var out fuse.AttrOut + if status := wfs.SetAttr(nil, in, &out); status != fuse.OK { + t.Fatalf("SetAttr size up: got %v, want OK", status) + } + if got := wfs.GetUncommittedBytes(); got != 8192-10 { + t.Fatalf("uncommitted bytes: got %d, want %d", got, 8192-10) + } + + // Truncating back down frees space rather than consuming it. + in.Size = 0 + if status := wfs.SetAttr(nil, in, &out); status != fuse.OK { + t.Fatalf("SetAttr size down: got %v, want OK", status) + } + if got := wfs.GetUncommittedBytes(); got != 8192-10 { + t.Fatalf("uncommitted bytes after a shrink: got %d, want %d", got, 8192-10) + } +} diff --git a/weed/mount/weedfs_file_fallocate.go b/weed/mount/weedfs_file_fallocate.go new file mode 100644 index 000000000..755ca11a3 --- /dev/null +++ b/weed/mount/weedfs_file_fallocate.go @@ -0,0 +1,72 @@ +package mount + +import ( + "syscall" + "time" + + "github.com/seaweedfs/go-fuse/v2/fuse" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/util" +) + +// See https://man7.org/linux/man-pages/man2/fallocate.2.html +const FALLOC_FL_KEEP_SIZE uint32 = 0x01 + +// Fallocate allocates space for an open file. Volume space is assigned when a +// write is flushed, so nothing can be reserved up front; only a range past the +// end of the file has an effect, growing it the way a truncate would. +func (wfs *WFS) Fallocate(cancel <-chan struct{}, in *fuse.FallocateIn) (code fuse.Status) { + + // ENOSYS makes the kernel stop sending FUSE_FALLOCATE for every mode, so a + // mode we cannot honor has to be refused on its own. + if in.Mode&^FALLOC_FL_KEEP_SIZE != 0 { + return fuse.ENOTSUP + } + + fh := wfs.GetHandle(FileHandleId(in.Fh)) + if fh == nil { + return fuse.EBADF + } + + fhActiveLock := fh.wfs.fhLockTable.AcquireLock("Fallocate", fh.fh, util.ExclusiveLock) + defer fh.wfs.fhLockTable.ReleaseLock(fh.fh, fhActiveLock) + + entry := fh.GetEntry().GetEntry() + if entry == nil { + return fuse.ENOENT + } + + oldFileSize := filer.FileSize(entry) + newFileSize := in.Offset + in.Length + if in.Mode&FALLOC_FL_KEEP_SIZE != 0 || newFileSize <= oldFileSize { + return fuse.OK + } + + if wfs.IsOverQuotaWithUncommitted() { + return fuse.Status(syscall.ENOSPC) + } + + if wormEnforced, _ := wfs.wormEnforcedForEntry(fh.FullPath(), entry); wormEnforced { + return fuse.EPERM + } + + glog.V(4).Infof("Fallocate %s fh %d grow to %d", fh.FullPath(), fh.fh, newFileSize) + + entry.Attributes.FileSize = newFileSize + // The writes that fill the range will not grow the file, so they charge + // nothing; the growth is counted here or the quota never sees it. + wfs.AddUncommittedBytes(int64(newFileSize - oldFileSize)) + + now := time.Now() + entry.Attributes.Mtime = now.Unix() + entry.Attributes.MtimeNs = int32(now.Nanosecond()) + entry.Attributes.Ctime = now.Unix() + entry.Attributes.CtimeNs = int32(now.Nanosecond()) + + fh.dirtyMetadata = true + wfs.invalidateOpenMtimeCache(in.NodeId) + + return fuse.OK +} diff --git a/weed/mount/weedfs_file_fallocate_test.go b/weed/mount/weedfs_file_fallocate_test.go new file mode 100644 index 000000000..a0e8c934f --- /dev/null +++ b/weed/mount/weedfs_file_fallocate_test.go @@ -0,0 +1,159 @@ +package mount + +import ( + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/seaweedfs/go-fuse/v2/fuse" + "google.golang.org/protobuf/proto" + + "github.com/seaweedfs/seaweedfs/weed/filer" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/util" +) + +// newOpenFileHandle builds a WFS holding one open handle on a file of the given +// size, the state a descriptor is in after an open and before any write. +func newOpenFileHandle(t *testing.T, fileSize uint64) (*WFS, *FileHandle) { + t.Helper() + + wfs := newCopyRangeTestWFS() + path := util.FullPath("/file.txt") + inode := wfs.inodeToPath.Lookup(path, 1, false, false, 0, true) + fh, _ := wfs.fhMap.AcquireFileHandle(wfs, inode, &filer_pb.Entry{ + Name: "file.txt", + Attributes: &filer_pb.FuseAttributes{ + FileMode: 0100644, + FileSize: fileSize, + Inode: inode, + }, + }, 0, 0) + fh.RememberPath(path) + + return wfs, fh +} + +// TestFallocateWithinFile covers the posix_fallocate() case that used to fall +// back to a glibc emulation reading through a write-only descriptor: the range +// is already inside the file, so the file must be left alone and answered OK. +func TestFallocateWithinFile(t *testing.T) { + wfs, fh := newOpenFileHandle(t, 10) + + in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 1} + if status := wfs.Fallocate(nil, in); status != fuse.OK { + t.Fatalf("Fallocate inside the file: got %v, want OK", status) + } + if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 10 { + t.Fatalf("file size: got %d, want 10", got) + } + if fh.dirtyMetadata { + t.Fatal("Fallocate inside the file marked the handle dirty") + } +} + +func TestFallocateGrowsFile(t *testing.T) { + wfs, fh := newOpenFileHandle(t, 10) + + in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 4096, Length: 4096} + if status := wfs.Fallocate(nil, in); status != fuse.OK { + t.Fatalf("Fallocate past the end: got %v, want OK", status) + } + if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 8192 { + t.Fatalf("file size: got %d, want 8192", got) + } + if !fh.dirtyMetadata { + t.Fatal("Fallocate past the end did not mark the handle dirty") + } +} + +func TestFallocateKeepSize(t *testing.T) { + wfs, fh := newOpenFileHandle(t, 10) + + in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 4096, Mode: FALLOC_FL_KEEP_SIZE} + if status := wfs.Fallocate(nil, in); status != fuse.OK { + t.Fatalf("Fallocate with FALLOC_FL_KEEP_SIZE: got %v, want OK", status) + } + if got := fh.GetEntry().GetEntry().GetAttributes().GetFileSize(); got != 10 { + t.Fatalf("file size: got %d, want 10", got) + } +} + +// TestFallocateUnsupportedMode pins the status for a mode we cannot honor: +// ENOSYS would make the kernel stop sending FUSE_FALLOCATE altogether. +func TestFallocateUnsupportedMode(t *testing.T) { + wfs, fh := newOpenFileHandle(t, 10) + + const punchHole = 0x02 + in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 4, Mode: punchHole | FALLOC_FL_KEEP_SIZE} + if status := wfs.Fallocate(nil, in); status != fuse.ENOTSUP { + t.Fatalf("Fallocate with FALLOC_FL_PUNCH_HOLE: got %v, want ENOTSUP", status) + } +} + +func TestFallocateUnknownHandle(t *testing.T) { + wfs, _ := newOpenFileHandle(t, 10) + + in := &fuse.FallocateIn{Fh: 12345, Offset: 0, Length: 1} + if status := wfs.Fallocate(nil, in); status != fuse.EBADF { + t.Fatalf("Fallocate on an unknown handle: got %v, want EBADF", status) + } +} + +// TestFallocateNoOpIgnoresQuotaAndWorm covers the two guards a request that +// allocates nothing must not trip: it reserves no space and rewrites no entry. +func TestFallocateNoOpIgnoresQuotaAndWorm(t *testing.T) { + wfs, fh := newOpenFileHandle(t, 10) + wfs.option.Quota = 1 + wfs.IsOverQuota = true + wfs.FilerConf = filer.NewFilerConf() + if err := wfs.FilerConf.AddLocationConf(&filer_pb.FilerConf_PathConf{ + LocationPrefix: "/", + Worm: proto.Bool(true), + }); err != nil { + t.Fatalf("AddLocationConf: %v", err) + } + fh.GetEntry().GetEntry().WormEnforcedAtTsNs = time.Now().UnixNano() + + in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 1} + if status := wfs.Fallocate(nil, in); status != fuse.OK { + t.Fatalf("Fallocate inside the file: got %v, want OK", status) + } + + in = &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 4096} + if status := wfs.Fallocate(nil, in); status != fuse.Status(syscall.ENOSPC) { + t.Fatalf("Fallocate past the end over quota: got %v, want ENOSPC", status) + } + + wfs.option.Quota = 0 + wfs.IsOverQuota = false + if status := wfs.Fallocate(nil, in); status != fuse.EPERM { + t.Fatalf("Fallocate past the end under worm: got %v, want EPERM", status) + } +} + +// TestFallocateChargesTheGrowth pins the quota accounting: the writes that fill +// a pre-extended range do not grow the file, so they charge nothing and the +// growth has to be counted where it happens. +func TestFallocateChargesTheGrowth(t *testing.T) { + atomic.StoreInt64(&uncommittedBytes, 0) + wfs, fh := newOpenFileHandle(t, 10) + wfs.option.Quota = 100 << 20 + + in := &fuse.FallocateIn{Fh: uint64(fh.fh), Offset: 0, Length: 8192} + if status := wfs.Fallocate(nil, in); status != fuse.OK { + t.Fatalf("Fallocate past the end: got %v, want OK", status) + } + if got := wfs.GetUncommittedBytes(); got != 8192-10 { + t.Fatalf("uncommitted bytes: got %d, want %d", got, 8192-10) + } + + // A second request inside the now-larger file allocates nothing more. + if status := wfs.Fallocate(nil, in); status != fuse.OK { + t.Fatalf("Fallocate inside the file: got %v, want OK", status) + } + if got := wfs.GetUncommittedBytes(); got != 8192-10 { + t.Fatalf("uncommitted bytes after a no-op: got %d, want %d", got, 8192-10) + } +} diff --git a/weed/mount/weedfs_unsupported.go b/weed/mount/weedfs_unsupported.go deleted file mode 100644 index 7a8c0af37..000000000 --- a/weed/mount/weedfs_unsupported.go +++ /dev/null @@ -1,17 +0,0 @@ -package mount - -import "github.com/seaweedfs/go-fuse/v2/fuse" - -// https://github.com/libfuse/libfuse/blob/48ae2e72b39b6a31cb2194f6f11786b7ca06aac6/include/fuse.h#L778 - -/** - * Allocates space for an open file - * - * This function ensures that required space is allocated for specified - * file. If this function returns success then any subsequent write - * request to specified range is guaranteed not to fail because of lack - * of space on the file system media. - */ -func (wfs *WFS) Fallocate(cancel <-chan struct{}, in *fuse.FallocateIn) (code fuse.Status) { - return fuse.ENOSYS -}