mount: let a readdirplus stop taking references at a cap

The inode table only shrinks when the kernel returns a reference, so a walk
over a large tree grows it with every child it touches and nothing gives any
of it back until the kernel reclaims dentries. On a mount with tens of
millions of files that is most of the process's heap.

-maxInodeEntries stops the speculative half of that. Past the cap a
readdirplus still reports every name, but leaves the EntryOut zeroed: Linux
reads a zero nodeid as 'no attributes for this entry', takes no reference, and
looks up the ones the client actually needs. A LOOKUP is never refused, so the
table can still grow past the cap by what a client really asks for, and
nothing here evicts. Default 0 keeps today's behavior.
This commit is contained in:
Chris Lu
2026-08-18 21:00:49 -07:00
parent ed75a61fb0
commit bd3b75b534
7 changed files with 75 additions and 7 deletions
+2
View File
@@ -21,6 +21,7 @@ type MountOptions struct {
concurrentReaders *int
cacheMetaTtlSec *int
cacheDirMaxEntries *int
maxInodeEntries *int
cacheDirForRead *string
cacheDirForWrite *string
cacheSizeMBForRead *int64
@@ -116,6 +117,7 @@ func init() {
mountOptions.writeBufferSizeMB = cmdMount.Flag.Int64("writeBufferSizeMB", 0, "global cap on the per-mount write buffer (memory + swap) in MB, 0 means unlimited. Bounds /tmp growth when volume uploads stall")
mountOptions.cacheMetaTtlSec = cmdMount.Flag.Int("cacheMetaTtlSec", 60, "metadata cache validity seconds")
mountOptions.cacheDirMaxEntries = cmdMount.Flag.Int("cacheDirMaxEntries", 100000, "a directory with more children than this is not cached locally but read directly from the filer; 0 caches everything")
mountOptions.maxInodeEntries = cmdMount.Flag.Int("maxInodeEntries", 0, "stop handing the kernel references from readdirplus once this many inodes are tracked; listings stay complete and the kernel looks up what it needs. Does not evict - the table shrinks only as the kernel returns references. 0 is unlimited")
mountOptions.dataCenter = cmdMount.Flag.String("dataCenter", "", "prefer to write to the data center")
mountOptions.allowOthers = cmdMount.Flag.Bool("allowOthers", true, "allows other users to access the file system")
mountOptions.defaultPermissions = cmdMount.Flag.Bool("defaultPermissions", true, "enforce permissions by the operating system")
+1
View File
@@ -204,6 +204,7 @@ func buildSeaweedFileSystem(option *MountOptions, p fileSystemParams) *mount.WFS
WriteBufferSizeMB: *option.writeBufferSizeMB,
CacheMetaTTlSec: *option.cacheMetaTtlSec,
CacheDirMaxEntries: *option.cacheDirMaxEntries,
MaxInodeEntries: *option.maxInodeEntries,
DataCenter: *option.dataCenter,
Quota: int64(*option.collectionQuota) * 1024 * 1024,
LogicalDiskUsage: *option.logicalDiskUsage,
+6
View File
@@ -214,6 +214,12 @@ func (i *InodeToPath) GetAllPaths(inode uint64) []util.FullPath {
return out
}
func (i *InodeToPath) Len() int {
i.RLock()
defer i.RUnlock()
return len(i.inode2path)
}
func (i *InodeToPath) HasPath(path util.FullPath) bool {
i.RLock()
defer i.RUnlock()
+5
View File
@@ -60,6 +60,11 @@ type Option struct {
DisableXAttr bool
IsMacOs bool
// MaxInodeEntries bounds what readdirplus adds, not the table itself: only
// a kernel FORGET releases an entry, and LOOKUP is never refused.
// 0 is unlimited.
MaxInodeEntries int
// LogicalDiskUsage reports data sizes rather than the space they occupy,
// for both df and the quota. See WFS.diskSizes.
LogicalDiskUsage bool
+10
View File
@@ -214,6 +214,13 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out DirEntrySink, isPlusMode
// Only a reference makes a child worth entering in the inode table: without
// one nothing ever arrives to take the entry back out again.
takesLookupRef := isPlusMode && out.TakesLookupRef()
// A zeroed EntryOut reads to the kernel as "no attributes for this entry":
// the name is still listed, but no reference is taken and none is owed back.
atInodeCap := takesLookupRef && wfs.option.MaxInodeEntries > 0 &&
wfs.inodeToPath.Len() >= wfs.option.MaxInodeEntries
if atInodeCap {
takesLookupRef = false
}
// index is the position in entryStream, used to calculate the offset for next readdir
processEachEntryFn := func(entry *filer.Entry, index int64) bool {
@@ -240,6 +247,9 @@ func (wfs *WFS) doReadDirectory(input *fuse.ReadIn, out DirEntrySink, isPlusMode
if entryOut == nil {
return false
}
if atInodeCap {
return true
}
if fh, found := wfs.fhMap.FindFileHandle(inode); found {
glog.V(4).Infof("readdir opened file %s", childPath)
entry = filer.FromPbEntry(string(dirPath), fh.GetEntry().GetEntry())
+1 -7
View File
@@ -62,12 +62,6 @@ func (s *benchSink) AddEntryPlus(entry fuse.DirEntry) *fuse.EntryOut {
func (s *benchSink) TakesLookupRef() bool { return s.takesRef }
func inodeTableSize(i *InodeToPath) int {
i.RLock()
defer i.RUnlock()
return len(i.inode2path)
}
func newBenchWFS(tb testing.TB, dir util.FullPath, n int) *WFS {
tb.Helper()
@@ -227,7 +221,7 @@ func BenchmarkReadDirectory(b *testing.B) {
b.StopTimer()
// What the listing left in the inode table, over the root and the
// directory itself.
b.ReportMetric(float64(inodeTableSize(wfs.inodeToPath)), "inodes_left")
b.ReportMetric(float64(wfs.inodeToPath.Len()), "inodes_left")
})
}
}
@@ -0,0 +1,50 @@
package mount
import (
"fmt"
"testing"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// Past the cap the listing is still complete, but takes no more references.
func TestReadDirPlusStopsAtInodeCap(t *testing.T) {
dir := util.FullPath("/images")
names := make([]string, 40)
for i := range names {
names[i] = fmt.Sprintf("f%03d.jpg", i)
}
wfs := newPagingWFS(t, dir, names, 0)
wfs.option.MaxInodeEntries = 10
dirInode, _ := wfs.inodeToPath.GetInode(dir)
sink := &benchSink{plus: true, takesRef: true, sinkLimit: 8}
if got := walkOnce(t, wfs, dirInode, sink, false); got != len(names)+2 {
t.Fatalf("listed %d entries, want %d", got, len(names)+2)
}
if got := wfs.inodeToPath.Len(); got >= len(names) {
t.Fatalf("inode table holds %d, want it capped well under %d", got, len(names))
}
// Starting at the cap, nothing gets attributes.
dhid, _ := wfs.AcquireDirectoryHandle()
defer wfs.ReleaseDirectoryHandle(dhid)
sink.reset()
status := wfs.doReadDirectory(&fuse.ReadIn{
InHeader: fuse.InHeader{NodeId: dirInode},
Fh: uint64(dhid),
Size: 1 << 20,
}, sink, true)
if status != fuse.OK {
t.Fatalf("readdir: %v", status)
}
if sink.count == 0 {
t.Fatal("listed nothing")
}
for i := range sink.attrs {
if sink.attrs[i].NodeId != 0 {
t.Fatalf("entry %d took a reference past the cap", i)
}
}
}