mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 16:28:30 +01:00
* 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>
233 lines
6.6 KiB
Go
233 lines
6.6 KiB
Go
package mount
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/seaweedfs/go-fuse/v2/fuse"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
// A mount with millions of files is mostly these, so guard the size class.
|
|
func TestInodeEntryStaysInSizeClass(t *testing.T) {
|
|
if unsafe.Sizeof(uintptr(0)) != 8 {
|
|
t.Skip("size classes are 64-bit specific")
|
|
}
|
|
if got := unsafe.Sizeof(InodeEntry{}); got != 32 {
|
|
t.Errorf("sizeof(InodeEntry) = %d, want 32", got)
|
|
}
|
|
}
|
|
|
|
func inodeEntryWith(paths ...util.FullPath) InodeEntry {
|
|
var ie InodeEntry
|
|
for _, p := range paths {
|
|
ie.addPath(p)
|
|
}
|
|
return ie
|
|
}
|
|
|
|
func TestInodeEntry_removeOnePath(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
entry InodeEntry
|
|
p util.FullPath
|
|
want bool
|
|
count int
|
|
}{
|
|
{
|
|
name: "actual case",
|
|
entry: inodeEntryWith("/pjd/nx", "/pjd/n0"),
|
|
p: "/pjd/nx",
|
|
want: true,
|
|
count: 1,
|
|
},
|
|
{
|
|
name: "empty",
|
|
entry: InodeEntry{},
|
|
p: "x",
|
|
want: false,
|
|
count: 0,
|
|
},
|
|
{
|
|
name: "single",
|
|
entry: inodeEntryWith("/x"),
|
|
p: "/x",
|
|
want: true,
|
|
count: 0,
|
|
},
|
|
{
|
|
name: "first",
|
|
entry: inodeEntryWith("/x", "/y", "/z"),
|
|
p: "/x",
|
|
want: true,
|
|
count: 2,
|
|
},
|
|
{
|
|
name: "middle",
|
|
entry: inodeEntryWith("/x", "/y", "/z"),
|
|
p: "/y",
|
|
want: true,
|
|
count: 2,
|
|
},
|
|
{
|
|
name: "last",
|
|
entry: inodeEntryWith("/x", "/y", "/z"),
|
|
p: "/z",
|
|
want: true,
|
|
count: 2,
|
|
},
|
|
{
|
|
name: "not found",
|
|
entry: inodeEntryWith("/x", "/y", "/z"),
|
|
p: "/t",
|
|
want: false,
|
|
count: 3,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := tt.entry.removeOnePath(tt.p); got != tt.want {
|
|
t.Errorf("removeOnePath() = %v, want %v", got, tt.want)
|
|
}
|
|
left := tt.entry.appendPaths(nil)
|
|
if tt.count != len(left) {
|
|
t.Errorf("removeOnePath path count = %v, want %v", len(left), tt.count)
|
|
}
|
|
for i, p := range left {
|
|
if p == tt.p {
|
|
t.Errorf("removeOnePath found path still exists at %v, %v", i, p)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Only directory inodes carry dirState; files never do. This keeps the file
|
|
// InodeEntry in the smaller size class, which is the memory win on large mounts.
|
|
func TestOnlyDirectoriesGetDirState(t *testing.T) {
|
|
itp := NewInodeToPath(util.FullPath("/"), 60)
|
|
|
|
file := util.FullPath("/data/file.txt")
|
|
fileInode := itp.Lookup(file, time.Now().Unix(), false, false, 0, true)
|
|
if _, ok := itp.dirStates[fileInode]; ok {
|
|
t.Fatal("file inode must not have a dirState")
|
|
}
|
|
// dir queries against a file are no-ops and must not register one
|
|
itp.GetSubdirCount(file)
|
|
itp.IsChildrenCached(file)
|
|
itp.ShouldReadDirectoryDirect(file)
|
|
itp.MarkChildrenCached(file)
|
|
if _, ok := itp.dirStates[fileInode]; ok {
|
|
t.Fatal("dir queries on a file inode must not create a dirState")
|
|
}
|
|
|
|
dir := util.FullPath("/data")
|
|
dirInode := itp.Lookup(dir, time.Now().Unix(), true, false, 0, true)
|
|
if _, ok := itp.dirStates[dirInode]; !ok {
|
|
t.Fatal("directory inode should be registered in dirStates at creation")
|
|
}
|
|
|
|
// forgetting the directory drops its dirState
|
|
itp.Forget(dirInode, 1, nil, nil)
|
|
if _, ok := itp.dirStates[dirInode]; ok {
|
|
t.Fatal("forgotten directory must be removed from dirStates")
|
|
}
|
|
}
|
|
|
|
func TestMarkChildrenCachedClearsReadThroughMode(t *testing.T) {
|
|
root := util.FullPath("/")
|
|
dir := util.FullPath("/data")
|
|
|
|
inodeToPath := NewInodeToPath(root, 60)
|
|
inodeToPath.Lookup(dir, time.Now().Unix(), true, false, 0, true)
|
|
|
|
if !inodeToPath.MarkDirectoryReadThrough(dir, time.Now()) {
|
|
t.Fatal("expected read-through flag to be set")
|
|
}
|
|
inodeToPath.MarkChildrenCached(dir)
|
|
|
|
if !inodeToPath.IsChildrenCached(dir) {
|
|
t.Fatal("directory should be cached after MarkChildrenCached")
|
|
}
|
|
if inodeToPath.ShouldReadDirectoryDirect(dir) {
|
|
t.Fatal("directory should leave read-through mode after caching")
|
|
}
|
|
}
|
|
|
|
// State keyed by an inode number has to be dropped while the table is locked.
|
|
// The numbers come from the path, so a lookup racing a forget is handed the
|
|
// same one back, and a cleanup running after the unlock would wipe what that
|
|
// lookup just stored.
|
|
func TestForgetOnReleaseRunsUnderLock(t *testing.T) {
|
|
itp := NewInodeToPath(util.FullPath("/"), 60)
|
|
file := util.FullPath("/data/f.txt")
|
|
inode := itp.Lookup(file, time.Now().Unix(), false, false, 0, true)
|
|
|
|
itp.Lookup(file, time.Now().Unix(), false, false, 0, true) // nlookup is 2 now
|
|
|
|
calls := 0
|
|
onRelease := func(released uint64) {
|
|
calls++
|
|
if released != inode {
|
|
t.Errorf("released inode = %d, want %d", released, inode)
|
|
}
|
|
if itp.TryLock() {
|
|
itp.Unlock()
|
|
t.Error("onRelease ran outside the critical section")
|
|
}
|
|
}
|
|
|
|
itp.Forget(inode, 1, onRelease, nil)
|
|
if calls != 0 {
|
|
t.Fatalf("partial forget must not release: onRelease called %d times", calls)
|
|
}
|
|
|
|
itp.Forget(inode, 1, onRelease, nil)
|
|
if calls != 1 {
|
|
t.Fatalf("onRelease called %d times, want 1", calls)
|
|
}
|
|
}
|
|
|
|
// A move with no source is not a move. Deciding that inside the lock is what
|
|
// keeps two invalidations racing on the same rename from unlinking each other's
|
|
// work.
|
|
func TestMovePathWithNoSourceLeavesTheTarget(t *testing.T) {
|
|
itp := NewInodeToPath(util.FullPath("/"), 0)
|
|
inode := itp.Lookup("/a/f.txt", time.Now().Unix(), false, false, 0, true)
|
|
|
|
itp.MovePath("/a/f.txt", "/a/g.txt")
|
|
if sourceInode, targetInode := itp.MovePath("/a/f.txt", "/a/g.txt"); sourceInode != 0 || targetInode != 0 {
|
|
t.Errorf("repeat move reported %d -> %d, want nothing moved", sourceInode, targetInode)
|
|
}
|
|
|
|
if got, status := itp.GetPath(inode); status != fuse.OK || got != "/a/g.txt" {
|
|
t.Errorf("inode resolves to %q (%v), want /a/g.txt", got, status)
|
|
}
|
|
if got, found := itp.GetInode("/a/g.txt"); !found || got != inode {
|
|
t.Errorf("/a/g.txt resolves to %d (found %v), want %d", got, found, inode)
|
|
}
|
|
}
|
|
|
|
// A second hard link name found by a lookup belongs to the inode, so it is
|
|
// reported with the others and released by the final forget.
|
|
func TestLookupRecordsHardLinkNames(t *testing.T) {
|
|
table := NewInodeToPath(util.FullPath("/"), 0)
|
|
inode := table.Lookup("/a", 1, false, true, 42, true)
|
|
if got := table.Lookup("/b", 1, false, true, 42, true); got != inode {
|
|
t.Fatalf("second name got inode %d, want %d", got, inode)
|
|
}
|
|
table.Lookup("/b", 1, false, true, 42, true)
|
|
if got := table.GetAllPaths(inode); len(got) != 2 || got[0] != "/a" || got[1] != "/b" {
|
|
t.Fatalf("paths %v, want [/a /b]", got)
|
|
}
|
|
table.RemovePath("/a", nil)
|
|
if got, status := table.GetPath(inode); status != fuse.OK || got != "/b" {
|
|
t.Fatalf("after removing /a the inode answers to %q (%v), want /b", got, status)
|
|
}
|
|
table.Forget(inode, 3, nil, nil)
|
|
if _, found := table.GetInode("/b"); found {
|
|
t.Fatal("the final forget left /b mapped to a released inode")
|
|
}
|
|
}
|