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
This commit is contained in:
Chris Lu
2026-04-10 14:08:04 -07:00
parent cd82a9cb4b
commit 178532e63a
5 changed files with 44 additions and 4 deletions
+2
View File
@@ -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")
+1
View File
@@ -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
+6
View File
@@ -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
}
+31 -4
View File
@@ -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
}
+4
View File
@@ -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
}