fix(mount): pin rebuild entries by their own inode, not inodeToPath (#9993)

isLocalOnlyEntry resolved the pinned-child check through inodeToPath. A kernel
Forget drops the path→inode mapping once the lookup count reaches zero, but an
async writeback flush — and the file handle, still in fhMap during the drain —
is keyed by inode and outlives that mapping. Between Release dispatching the
async flush and the flush reaching the filer, a Forget could unpin an in-flight
create, so a concurrent directory rebuild would wipe it and the file would
ENOENT until the flush lands and the cache refreshes.

Key the check off the inode the store entry already carries (createFile stamps
it into the placeholder), so the pin no longer depends on a mapping Forget can
remove.
This commit is contained in:
Chris Lu
2026-06-16 14:59:13 -07:00
committed by GitHub
parent b888cec106
commit 1826a5d222
3 changed files with 23 additions and 11 deletions
+3 -3
View File
@@ -33,7 +33,7 @@ type MetaCache struct {
isCachedFn func(fullpath util.FullPath) bool
invalidateFunc func(fullpath util.FullPath, entry *filer_pb.Entry)
onDirectoryUpdate func(dir util.FullPath)
pinnedChildFn func(util.FullPath) bool // a child a rebuild must not drop (local-only, not yet on the filer); nil disables
pinnedChildFn func(*filer.Entry) bool // a child a rebuild must not drop (local-only, not yet on the filer); nil disables
visitGroup singleflight.Group // deduplicates concurrent EnsureVisited calls for the same path
applyCh chan metadataApplyRequest
applyDone chan struct{}
@@ -342,7 +342,7 @@ func (mc *MetaCache) DeleteFolderChildren(ctx context.Context, fp util.FullPath)
// SetPinnedChildFn installs a predicate reporting whether a child holds
// local-only state a rebuild must not discard. See deleteFolderChildrenForRebuild.
func (mc *MetaCache) SetPinnedChildFn(fn func(util.FullPath) bool) {
func (mc *MetaCache) SetPinnedChildFn(fn func(*filer.Entry) bool) {
mc.pinnedChildFn = fn
}
@@ -360,7 +360,7 @@ func (mc *MetaCache) deleteFolderChildrenForRebuild(ctx context.Context, dirPath
}
var pinned []*filer.Entry
if _, err := mc.localStore.ListDirectoryEntries(ctx, dirPath, "", true, math.MaxInt64, func(entry *filer.Entry) (bool, error) {
if mc.pinnedChildFn(entry.FullPath) {
if mc.pinnedChildFn(entry) {
pinned = append(pinned, entry)
}
return true, nil
+12 -4
View File
@@ -473,11 +473,19 @@ func TestEnsureVisitedPreservesLocalOnlyEntry(t *testing.T) {
mc, _, _, _ := newTestMetaCache(t, map[util.FullPath]bool{"/": true})
defer mc.Shutdown()
// The mount pins the un-flushed create (open dirty handle / pending flush).
mc.SetPinnedChildFn(func(p util.FullPath) bool { return p == "/dir/pending.txt" })
// The mount pins the un-flushed create (open dirty handle / pending flush),
// keyed off the inode the entry carries so a kernel Forget that dropped the
// path→inode mapping cannot unpin an in-flight create.
mc.SetPinnedChildFn(func(e *filer.Entry) bool { return e.Attr.Inode == 42 })
// A deferred local create lands before the rebuild; /dir is not yet cached.
insertCacheEntry(t, mc, "/dir/pending.txt")
// It carries its allocated inode, as createFile's placeholder does.
if err := mc.InsertEntry(context.Background(), &filer.Entry{
FullPath: "/dir/pending.txt",
Attr: filer.Attr{Crtime: time.Unix(1, 0), Mtime: time.Unix(1, 0), Mode: 0100644, FileSize: 1, Inode: 42},
}); err != nil {
t.Fatalf("insert pending entry: %v", err)
}
// A concurrent rebuild lists the filer, whose snapshot pre-dates the
// un-flushed create, so it returns only the already-persisted sibling.
@@ -522,7 +530,7 @@ func TestEnsureVisitedDropsUnpinnedStaleEntry(t *testing.T) {
mc, _, _, _ := newTestMetaCache(t, map[util.FullPath]bool{"/": true})
defer mc.Shutdown()
mc.SetPinnedChildFn(func(util.FullPath) bool { return false })
mc.SetPinnedChildFn(func(*filer.Entry) bool { return false })
// A stale child sits in the cache; the filer no longer has it.
insertCacheEntry(t, mc, "/dir/stale.txt")
+8 -4
View File
@@ -550,16 +550,20 @@ func (wfs *WFS) maybeReadEntry(inode uint64) (path util.FullPath, fh *FileHandle
return
}
// isLocalOnlyEntry reports whether fullpath holds local-only state not yet on the
// isLocalOnlyEntry reports whether entry holds local-only state not yet on the
// filer — an open handle with dirty metadata, or a pending async flush. A
// directory rebuild refills from a filer listing that omits such an entry, so it
// must be preserved across the wipe; this is the same signal lookupEntry trusts
// over a filer ErrNotFound for deferred creates.
func (wfs *WFS) isLocalOnlyEntry(fullpath util.FullPath) bool {
inode, found := wfs.inodeToPath.GetInode(fullpath)
if !found {
//
// Keyed off the inode the entry carries, not inodeToPath: a kernel Forget can
// drop the path→inode mapping while an async writeback flush is still in flight,
// and the entry must stay pinned until that flush reaches the filer.
func (wfs *WFS) isLocalOnlyEntry(entry *filer.Entry) bool {
if entry == nil || entry.Attr.Inode == 0 {
return false
}
inode := entry.Attr.Inode
if fh, fhFound := wfs.fhMap.FindFileHandle(inode); fhFound && fh.dirtyMetadata {
return true
}