From 178532e63a4588089e75320dfc321e5494086f4f Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 10 Apr 2026 12:26:18 -0700 Subject: [PATCH] fix(mount): add -posixDirNlink option for correct directory nlink Add optional POSIX-compliant directory nlink counting (nlink = 2 + number_of_subdirectories) behind the -posixDirNlink flag. This requires listing cached directory entries on every stat, which has a performance cost, so it's off by default. When disabled (default), directories report nlink=2, which is the POSIX-compliant baseline for empty directories and a common default in distributed filesystems. - Set nlink baseline to 2 for all directories (was 1) - Add applyDirNlink() gated behind option.PosixDirNlink - Add -posixDirNlink CLI flag --- weed/command/mount.go | 2 ++ weed/command/mount_std.go | 1 + weed/mount/weedfs.go | 6 ++++++ weed/mount/weedfs_attr.go | 35 +++++++++++++++++++++++++++++---- weed/mount/weedfs_dir_lookup.go | 4 ++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/weed/command/mount.go b/weed/command/mount.go index c82fbd9ec..56c910ea7 100644 --- a/weed/command/mount.go +++ b/weed/command/mount.go @@ -56,6 +56,7 @@ type MountOptions struct { distributedLock *bool // FUSE performance options + posixDirNlink *bool writebackCache *bool asyncDio *bool cacheSymlink *bool @@ -132,6 +133,7 @@ func init() { mountOptions.distributedLock = cmdMount.Flag.Bool("dlm", false, "enable distributed lock for cross-mount write coordination (only one mount can write a file at a time)") // FUSE performance options + mountOptions.posixDirNlink = cmdMount.Flag.Bool("posix.dirNLink", false, "report POSIX-compliant directory nlink (2 + subdirectory count); costs one directory listing per stat") mountOptions.writebackCache = cmdMount.Flag.Bool("writebackCache", false, "enable FUSE writeback cache for improved write performance (at risk of data loss on crash)") mountOptions.asyncDio = cmdMount.Flag.Bool("asyncDio", false, "enable async direct I/O for better concurrency") mountOptions.cacheSymlink = cmdMount.Flag.Bool("cacheSymlink", false, "enable symlink caching to reduce metadata lookups") diff --git a/weed/command/mount_std.go b/weed/command/mount_std.go index 8228bbcd2..e0c0a6291 100644 --- a/weed/command/mount_std.go +++ b/weed/command/mount_std.go @@ -354,6 +354,7 @@ func RunMount(option *MountOptions, umask os.FileMode) bool { DirIdleEvictSec: *option.dirIdleEvictSec, EnableDistributedLock: option.distributedLock != nil && *option.distributedLock, WritebackCache: option.writebackCache != nil && *option.writebackCache, + PosixDirNlink: option.posixDirNlink != nil && *option.posixDirNlink, }) // create mount root diff --git a/weed/mount/weedfs.go b/weed/mount/weedfs.go index 992e70f80..4fc4e20ad 100644 --- a/weed/mount/weedfs.go +++ b/weed/mount/weedfs.go @@ -93,6 +93,12 @@ type Option struct { // When true, Flush() returns immediately and data upload + metadata flush happen in background. WritebackCache bool + // PosixDirNlink enables POSIX-compliant directory nlink counting + // (nlink = 2 + number_of_subdirectories). This requires listing + // cached directory entries on every stat, which has a performance cost. + // When false (default), directories report nlink=2. + PosixDirNlink bool + uniqueCacheDirForRead string uniqueCacheDirForWrite string } diff --git a/weed/mount/weedfs_attr.go b/weed/mount/weedfs_attr.go index bca4cb45b..194b7918c 100644 --- a/weed/mount/weedfs_attr.go +++ b/weed/mount/weedfs_attr.go @@ -1,6 +1,7 @@ package mount import ( + "context" "os" "syscall" "time" @@ -16,15 +17,21 @@ func (wfs *WFS) GetAttr(cancel <-chan struct{}, input *fuse.GetAttrIn, out *fuse glog.V(4).Infof("GetAttr %v", input.NodeId) if input.NodeId == 1 { wfs.setRootAttr(out) + if wfs.option.PosixDirNlink { + wfs.applyDirNlink(&out.Attr, util.FullPath(wfs.option.FilerMountRootPath)) + } return fuse.OK } inode := input.NodeId - _, _, entry, status := wfs.maybeReadEntry(inode) + path, _, entry, status := wfs.maybeReadEntry(inode) if status == fuse.OK { out.AttrValid = 1 wfs.setAttrByPbEntry(&out.Attr, inode, entry, true) wfs.applyInMemoryAtime(&out.Attr, inode) + if entry.IsDirectory && wfs.option.PosixDirNlink { + wfs.applyDirNlink(&out.Attr, path) + } return status } else { if fh, found := wfs.fhMap.FindFileHandle(inode); found { @@ -176,7 +183,7 @@ func (wfs *WFS) setRootAttr(out *fuse.AttrOut) { out.Ctime = now out.Atime = now out.Mode = toSyscallType(os.ModeDir) | uint32(wfs.option.MountMode) - out.Nlink = 1 + out.Nlink = 2 } func (wfs *WFS) setAttrByPbEntry(out *fuse.Attr, inode uint64, entry *filer_pb.Entry, calculateSize bool) { @@ -208,7 +215,9 @@ func (wfs *WFS) setAttrByPbEntry(out *fuse.Attr, inode uint64, entry *filer_pb.E out.Atimensec = uint32(entry.Attributes.MtimeNs) // In-memory atime overlay is applied by the caller via applyInMemoryAtime. out.Mode = toSyscallMode(os.FileMode(entry.Attributes.FileMode)) - if entry.HardLinkCounter > 0 { + if entry.IsDirectory { + out.Nlink = 2 + } else if entry.HardLinkCounter > 0 { out.Nlink = uint32(entry.HardLinkCounter) } else { out.Nlink = 1 @@ -238,7 +247,9 @@ func (wfs *WFS) setAttrByFilerEntry(out *fuse.Attr, inode uint64, entry *filer.E out.Ctimensec = uint32(entry.Attr.Mtime.Nanosecond()) } out.Mode = toSyscallMode(entry.Attr.Mode) - if entry.HardLinkCounter > 0 { + if entry.IsDirectory() { + out.Nlink = 2 + } else if entry.HardLinkCounter > 0 { out.Nlink = uint32(entry.HardLinkCounter) } else { out.Nlink = 1 @@ -306,6 +317,22 @@ func (wfs *WFS) applyInMemoryAtime(out *fuse.Attr, inode uint64) { wfs.atimeMu.Unlock() } +// applyDirNlink sets nlink = 2 + number_of_subdirectories for a directory. +// Only counts from the local metacache to avoid expensive filer queries. +// When the cache has no entries (e.g. before readdir), keeps nlink=2. +func (wfs *WFS) applyDirNlink(out *fuse.Attr, dirPath util.FullPath) { + var subdirCount uint32 + wfs.metaCache.ListDirectoryEntries(context.Background(), dirPath, "", false, 100000, func(entry *filer.Entry) (bool, error) { + if entry.IsDirectory() { + subdirCount++ + } + return true, nil + }) + if subdirCount > 0 { + out.Nlink = 2 + subdirCount + } +} + func chmod(existing uint32, mode uint32) uint32 { return existing&^07777 | mode&07777 } diff --git a/weed/mount/weedfs_dir_lookup.go b/weed/mount/weedfs_dir_lookup.go index de56551df..2c29d907a 100644 --- a/weed/mount/weedfs_dir_lookup.go +++ b/weed/mount/weedfs_dir_lookup.go @@ -44,6 +44,10 @@ func (wfs *WFS) Lookup(cancel <-chan struct{}, header *fuse.InHeader, name strin wfs.outputFilerEntry(out, inode, localEntry) + if localEntry.IsDirectory() && wfs.option.PosixDirNlink { + wfs.applyDirNlink(&out.Attr, fullFilePath) + } + return fuse.OK }