Files
seaweedfs/weed/mount/weedfs_peer_unlink_test.go
T
Chris LuandClaude Opus 5.5 4fc41872e3 mount: keep an open file publishing when a peer unlinks one of its hard link names (#12613)
* mount: record a hard link's looked-up names on its inode

Lookup mapped a second name of a hard-linked file to the existing inode
without adding it to the inode's names, so GetAllPaths missed it and the
final forget left it mapped to a released inode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* mount: test peer unlinks of a hard link name held open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: leave an unlinked file out of the periodic metadata flush

flushFileMetadata did not check the handle's delete mark. A file
unlinked while open before its deferred create reached the filer was
created again by the next periodic flush, which expects no entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: read a pending link change before a periodic metadata flush

Only flushMetadataToFiler applied linkRefresh. After a peer unlinked one
name, the periodic flush kept publishing over the old link count, so the
filer refused every one until a regular flush ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: test the periodic flush after a peer unlinks a hard link name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 16:14:22 -08:00

154 lines
5.3 KiB
Go

//go:build !freebsd
package mount
import (
"context"
"testing"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// peerUnlink removes name on the filer the way another mount does and
// delivers the delete event this mount's subscription receives.
func peerUnlink(t *testing.T, wfs *WFS, fake *hardLinkFiler, name string) {
t.Helper()
old := fake.stored(name)
if _, err := fake.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{Directory: "/", Name: name}); err != nil {
t.Fatalf("peer delete %s: %v", name, err)
}
fake.mu.Lock()
fake.stamp++
ts := fake.stamp
fake.mu.Unlock()
del := &filer_pb.SubscribeMetadataResponse{Directory: "/", TsNs: ts, EventNotification: &filer_pb.EventNotification{OldEntry: old}}
if err := wfs.metaCache.ApplyMetadataResponse(context.Background(), del, meta_cache.SubscriberMetadataResponseApplyOptions); err != nil {
t.Fatalf("apply peer delete: %v", err)
}
wfs.metaCache.WaitForEntryInvalidations()
}
// Another mount removes one of the two names of a file open here. The handle
// keeps publishing through the other name, over the link count and ctime the
// filer stored there, and the removed name stays removed.
func TestPeerUnlinkedAliasKeepsOpenHandlePublishing(t *testing.T) {
for _, removed := range []string{"tmp", "final"} {
for _, dirty := range []bool{false, true} {
t.Run(removed+map[bool]string{false: "/clean", true: "/dirty"}[dirty], func(t *testing.T) {
wfs, fake, fh, inode := openLinkTestFile(t)
linkInRoot(t, wfs, inode, "final")
chunks := 2
if dirty {
fh.UpdateEntry(func(e *filer_pb.Entry) {
e.Chunks = append(e.Chunks, linkHandleChunk(9, 100))
e.Attributes.FileSize = 200
})
fh.dirtyMetadata = true
chunks = 3
}
survivor := map[string]string{"tmp": "final", "final": "tmp"}[removed]
peerUnlink(t, wfs, fake, removed)
if fh.isDeleted {
t.Fatal("a peer unlinking one of two names marked the open handle deleted")
}
unlinked := fake.stored(survivor).Attributes
if code := appendAndFlush(t, wfs, fh, 2); code != fuse.OK {
t.Fatalf("flush after the peer unlink: %v", code)
}
wantStored(t, fake, survivor, chunks, 1)
if got := fake.stored(survivor).Attributes; got.Ctime != unlinked.Ctime || got.CtimeNs != unlinked.CtimeNs {
t.Fatalf("the flush moved the ctime from the unlink's %d.%09d back to %d.%09d", unlinked.Ctime, unlinked.CtimeNs, got.Ctime, got.CtimeNs)
}
if fake.stored(removed) != nil {
t.Fatalf("the flush recreated %s", removed)
}
})
}
}
}
// The surviving name was found by a lookup, not linked by this mount.
func TestPeerUnlinkedAliasKeepsLookedUpSurvivor(t *testing.T) {
wfs, fake, fh, inode := openLinkTestFile(t)
linkInRoot(t, wfs, inode, "final")
wfs.inodeToPath.RemovePath("/final", nil)
wfs.inodeToPath.Lookup("/final", 1, false, true, inode, true)
peerUnlink(t, wfs, fake, "tmp")
if fh.isDeleted {
t.Fatal("a peer unlinking the open name of a file with a looked-up second name marked the handle deleted")
}
if code := appendAndFlush(t, wfs, fh, 2); code != fuse.OK {
t.Fatalf("flush after the peer unlink: %v", code)
}
wantStored(t, fake, "final", 2, 1)
if fake.stored("tmp") != nil {
t.Fatal("the flush recreated tmp")
}
}
// With no other name known here, the handle stops rather than recreating the
// removed one.
func TestPeerUnlinkedAliasWithUnknownSurvivorStops(t *testing.T) {
wfs, fake, fh, inode := openLinkTestFile(t)
linkInRoot(t, wfs, inode, "final")
wfs.inodeToPath.RemovePath("/final", nil)
peerUnlink(t, wfs, fake, "tmp")
if !fh.isDeleted {
t.Fatal("a handle with no known surviving name was left publishing")
}
if code := appendAndFlush(t, wfs, fh, 2); code != fuse.OK {
t.Fatalf("flush: %v", code)
}
if fake.stored("tmp") != nil {
t.Fatal("the flush recreated tmp")
}
}
// A peer removes one name, then this mount unlinks the last one.
func TestPeerThenLocalUnlinkKeepsHandleDeleted(t *testing.T) {
wfs, fake, fh, inode := openLinkTestFile(t)
linkInRoot(t, wfs, inode, "final")
peerUnlink(t, wfs, fake, "final")
unlinkInRoot(t, wfs, "tmp")
if !fh.isDeleted {
t.Fatal("the handle of a file with no names left is not marked deleted")
}
if code := appendAndFlush(t, wfs, fh, 2); code != fuse.OK {
t.Fatalf("flush after the last unlink: %v", code)
}
for _, name := range []string{"tmp", "final"} {
if fake.stored(name) != nil {
t.Fatalf("the flush recreated %s", name)
}
}
}
func TestPeerUnlinkLastNameKeepsHandleDeleted(t *testing.T) {
wfs, fake, fh, _ := openLinkTestFile(t)
peerUnlink(t, wfs, fake, "tmp")
if !fh.isDeleted {
t.Fatal("a peer unlinking the only name did not mark the handle deleted")
}
}
// The periodic metadata flush publishes through the survivor as well.
func TestPeerUnlinkedAliasKeepsPeriodicFlushPublishing(t *testing.T) {
wfs, fake, fh, inode := openLinkTestFile(t)
linkInRoot(t, wfs, inode, "final")
peerUnlink(t, wfs, fake, "tmp")
fh.UpdateEntry(func(e *filer_pb.Entry) {
e.Chunks = append(e.Chunks, linkHandleChunk(2, 100))
e.Attributes.FileSize = 200
})
fh.dirtyMetadata = true
if err := wfs.flushFileMetadata(fh); err != nil {
t.Fatalf("periodic flush after the peer unlink: %v", err)
}
wantStored(t, fake, "final", 2, 1)
if fake.stored("tmp") != nil {
t.Fatal("the periodic flush recreated tmp")
}
}