From ee1de6d5334452e4da374c4f831f69739689aea5 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 13 Nov 2026 02:51:33 -0800 Subject: [PATCH] mount: keep an open file publishing when a peer unlinks one of its names A delete event from another client marked the inode's open handle deleted even when other names still held the hard-linked file, so every later fsync reported success without saving metadata. The delete event's entry carries the link count the filer decided on, so an alias delete now drops only that name, keeps the handle on a name this mount knows, and has the next flush read the survivor's stored entry before publishing. Co-Authored-By: Claude Opus 5.5 --- weed/mount/meta_cache/meta_cache.go | 9 ++++++++- weed/mount/weedfs.go | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/weed/mount/meta_cache/meta_cache.go b/weed/mount/meta_cache/meta_cache.go index d5f89c7294..e4c16979df 100644 --- a/weed/mount/meta_cache/meta_cache.go +++ b/weed/mount/meta_cache/meta_cache.go @@ -937,6 +937,9 @@ type EntryInvalidation struct { // once the path is empty, so this is the only thing left saying whether a // directory or a file went away. WasDirectory bool + // LiveHardLinkId is set on a delete that left other names holding the + // hard-linked file, by the count the filer kept its chunks on. + LiveHardLinkId []byte } type metadataResponseSideEffects struct { @@ -1440,7 +1443,11 @@ func collectEntryInvalidations(resp *filer_pb.SubscribeMetadataResponse) []Entry if filer_pb.IsDelete(resp) && message.OldEntry != nil { oldKey := util.NewFullPath(resp.Directory, message.OldEntry.Name) - invalidations = append(invalidations, EntryInvalidation{Path: oldKey, TsNs: resp.TsNs, Deleted: true, Signatures: signatures, WasDirectory: message.OldEntry.IsDirectory}) + invalidation := EntryInvalidation{Path: oldKey, TsNs: resp.TsNs, Deleted: true, Signatures: signatures, WasDirectory: message.OldEntry.IsDirectory} + if len(message.OldEntry.HardLinkId) > 0 && message.OldEntry.HardLinkCounter > 1 { + invalidation.LiveHardLinkId = message.OldEntry.HardLinkId + } + invalidations = append(invalidations, invalidation) } return invalidations diff --git a/weed/mount/weedfs.go b/weed/mount/weedfs.go index 5cd89e448a..4f528a070c 100644 --- a/weed/mount/weedfs.go +++ b/weed/mount/weedfs.go @@ -1023,6 +1023,12 @@ func (wfs *WFS) invalidateOpenFileHandle(invalidation meta_cache.EntryInvalidati // so no flush recreates the unlinked name. Either way the entry and // dirty pages stay, so the open fd still reads its buffered writes. if invalidation.Deleted { + // The next flush reads the survivor's stored entry before + // publishing, so the version stays where it was. + if len(invalidation.LiveHardLinkId) > 0 && wfs.followSurvivingLink(fh, inode, filePath) { + fh.linkRefresh = invalidation.LiveHardLinkId + return + } fh.isDeleted = true fh.deleteEpoch++ } @@ -1061,6 +1067,24 @@ func (wfs *WFS) invalidateOpenFileHandle(invalidation meta_cache.EntryInvalidati return } +// followSurvivingLink drops a hard link name another client deleted and +// keeps the handle on a name of the inode this mount still knows, reporting +// false when it knows none. +func (wfs *WFS) followSurvivingLink(fh *FileHandle, inode uint64, removed util.FullPath) bool { + survives := false + for _, p := range wfs.inodeToPath.GetAllPaths(inode) { + survives = survives || p != removed + } + if !survives { + return false + } + wfs.inodeToPath.RemovePath(removed, nil) + if survivor, status := wfs.inodeToPath.GetPath(inode); status == fuse.OK { + fh.RememberPath(survivor) + } + return true +} + func (wfs *WFS) LookupFn() wdclient.LookupFileIdFunctionType { if wfs.option.VolumeServerAccess == "filerProxy" { return func(ctx context.Context, fileId string) (targetUrls []string, err error) {