From 624deaf3a4fcc339d4a10a7df22057e60da36def Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 28 Aug 2026 14:56:50 -0700 Subject: [PATCH] mount: implement fallocate (#11021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * mount: implement fallocate instead of reporting it unsupported Fallocate answered ENOSYS, so the kernel marked the mount as having no fallocate and returned EOPNOTSUPP. glibc then fell back to its emulation, which preads a byte from every block already inside the file to see if it is allocated; on a write-only descriptor that pread is EBADF, and posix_fallocate returned it. Volume space is assigned when a write is flushed, so nothing can be reserved up front: a range inside the file is answered OK untouched, and one past the end grows the file the way a truncate would. A mode we cannot honor is refused with ENOTSUP, not ENOSYS, so the kernel keeps sending the ones we do. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 * mount: let a fallocate that allocates nothing past the quota and worm guards A range already inside the file, and any FALLOC_FL_KEEP_SIZE request, reserve no space and rewrite no entry, but the preflight refused them with ENOSPC on a full mount and EPERM on a worm-enforced file. Decide the no-op first and guard only the growth. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 * mount: charge a fallocate growth to the uncommitted byte counter Write charges the counter by how much the file grew, so the writes that fill a range fallocate already extended charge nothing and the real-time quota check never sees that data — only the periodic filer refresh does. Count the growth where it happens. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 * mount: charge a truncate-up growth to the uncommitted byte counter Same gap Fallocate had: Write charges the counter by how much the file grew, so the writes that fill a range ftruncate already extended charge nothing and the real-time quota check never sees that data. Count the growth where it happens; a shrink still leaves the counter alone, since it is only ever raised and then reset by the periodic filer refresh. Claude-Session: https://claude.ai/code/session_01XG6sAAdkqTwWffqK5h4rH9 --- weed/mount/weedfs_attr.go | 9 +- weed/mount/weedfs_attr_quota_test.go | 38 ++++++ weed/mount/weedfs_file_fallocate.go | 72 ++++++++++ weed/mount/weedfs_file_fallocate_test.go | 159 +++++++++++++++++++++++ weed/mount/weedfs_unsupported.go | 17 --- 5 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 weed/mount/weedfs_attr_quota_test.go create mode 100644 weed/mount/weedfs_file_fallocate.go create mode 100644 weed/mount/weedfs_file_fallocate_test.go delete mode 100644 weed/mount/weedfs_unsupported.go 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 -}