mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-30 17:00:48 +01:00
* mount: rebuild dropped directory listings on lookup Invalidation, idle eviction, and a kernel Forget all clear a directory's cached-listing flag, but only an expired listing was rebuilt on lookup — a dropped one read through per entry until a ReadDir happened to run. Track directories that once held a listing in wasCachedDirs, which survives those drops, and let ShouldRebuildExpiredDir rebuild them on the next lookup. Never-cached and read-through directories keep reading through. * mount: re-validate a stale directory section on lookup A burst of remote changes invalidates only the directory section it lands in, but nothing re-validates that section until a ReadDir runs — so a lookup-heavy workload reads through per entry indefinitely. EnsureNameFresh re-lists just the section covering the looked-up name, after which lookups in it are served from the cache again. * mount: wait for pending flush before a stale-section refresh on lookup A section refresh lists the range from the filer; without the wait a pending async flush of the looked-up entry could land after the listing and leave the refreshed section holding pre-flush metadata — the same hazard the rebuild path already waits out. * mount: bound the dropped-listing rebuild history wasCachedDirs grew one full path per directory ever cached, removed only by the read-through mark, so deleted and forgotten directories accumulated forever. Remove the record when its name is deleted, move it on rename so rebuild eligibility travels with the directory, and cap the history with FIFO eviction — past the bound an evicted directory reads through per entry, the same end state as a never-cached one. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * mount: drop oversized stale sections and back off failed refreshes A section that outgrew one refresh failed EnsureNameFresh with ErrRefreshRangeTooLarge on every lookup, repeating the multi-page listing before each per-entry read, and other refresh failures retried with no cooldown. Drop the cached listing on ErrRefreshRangeTooLarge so a later lookup rebuilds it re-tiled, as ReadDir does, and stamp the rebuild attempt on other failures so retries back off under the same cooldown the rebuild gate uses. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * mount: version hard-link sibling syncs at the committed log position * mount: tag dropped-listing queue slots so stale ones cannot evict RemovePath, MovePath, and MarkDirectoryReadThrough delete the rebuild record but leave the FIFO slot behind, so a path removed and re-cached holds two queue entries. When the stale slot reaches the front it evicts the newer record, and the directory reads through per lookup until the next listing. Tag each record with an insertion generation and only evict on a match, keeping the newer record for its own slot's turn. * mount: refresh hard-link siblings from the filer, not the cached copy Stamping a cached sibling with the mutation's version claimed that position for content that may predate it — an in-flight event with newer chunks or attributes then hits the version gate and the sibling serves stale data until a refresh. Fetch each cached sibling's entry from the filer after the commit and record the higher of the fetched and committed positions, falling back to the in-place counter patch only when the fetch itself fails. * mount: back off failed section refreshes per section, not per directory A failed refresh of one stale section marked a directory-wide attempt, so every other stale section in that directory waited out the cooldown with per-entry filer reads. Key the attempt timestamp by the section's lower bound so unrelated ranges still refresh on their own schedule. * mount: fence hard-linked entries by the group's newest write position Every path of a hard-link group reads one shared content blob, but the version gate only tracks per-path records — a sibling absent from inodeToPath got no version from the sibling sync, so a stale section refresh or event could roll the shared blob back for all of them. Record a monotonic version floor per hard-link id wherever versioned content lands, and check it in the section-refresh and event gates. * mount: keep hard-link names landing when the group floor fences content The per-group version floor rejected a delayed event's newEntry while its oldPath still applied — a rename could drop the source without adding the destination, and a section refresh could skip then mark a live link fresh. Rebase the group's current cached content onto the new path so the name lands without rolling the shared blob back, only skip refresh entries already cached, and gate the sibling refresh write under the same lock so a racing event or delete wins. * mount: rebase hard-link entries from the shared blob when no sibling is cached Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * mount: park group content on uncached hard-link names a stale refresh installs Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * mount: stale the destination section when a fenced hard-link name cannot land Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * mount: bound the sibling hard-link refresh under one request deadline Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * mount: replace a reused-name occupant the hard-link floor would skip * mount: claim no version for a sibling counter patched without a fetch * mount: vacate a reused-name occupant without decrementing its group A sibling write can fold the occupant's filer-side unlink into the shared blob counter while the stale entry is still cached, so replacing it through the plain insert may decrement a group that no longer counts the name and drop a blob live siblings still read. Vacate the path like a rename first; the counter then only ever drifts high, which leaks a blob rather than losing shared content. * mount: keep a fetch-failed sibling's version when patching its counter The fallback marked the entry unversioned, which fences nothing — a replayed delete older than the cached content could remove a live link. Claim the version the entry already holds instead: newer in-flight events still land and correct it, older replays stay fenced. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package mount
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/go-fuse/v2/fuse"
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
// linkTestSource seeds the meta cache with an entry and registers it with
|
|
// inodeToPath the way a kernel Lookup would, returning the node id the kernel
|
|
// would then use as LinkIn.Oldnodeid.
|
|
func linkTestSource(t *testing.T, wfs *WFS, name string, persistedInode uint64) (util.FullPath, uint64) {
|
|
t.Helper()
|
|
|
|
entry := &filer_pb.Entry{
|
|
Name: name,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
FileMode: 0o644,
|
|
FileSize: 12,
|
|
Inode: persistedInode,
|
|
Crtime: 1,
|
|
Mtime: 1,
|
|
Uid: 99,
|
|
Gid: 100,
|
|
},
|
|
}
|
|
if err := wfs.metaCache.InsertEntry(context.Background(), filer.FromPbEntry("/", entry), 0); err != nil {
|
|
t.Fatalf("InsertEntry: %v", err)
|
|
}
|
|
|
|
fullPath := util.FullPath("/").Child(name)
|
|
inode := wfs.inodeToPath.Lookup(fullPath, entry.Attributes.Crtime, false, false, persistedInode, true)
|
|
if inode == 0 {
|
|
t.Fatal("Lookup handed out node id 0")
|
|
}
|
|
return fullPath, inode
|
|
}
|
|
|
|
func doLink(t *testing.T, wfs *WFS, oldNodeId uint64, name string) *fuse.EntryOut {
|
|
t.Helper()
|
|
|
|
out := &fuse.EntryOut{}
|
|
status := wfs.Link(make(chan struct{}), &fuse.LinkIn{
|
|
InHeader: fuse.InHeader{
|
|
NodeId: 1,
|
|
Caller: fuse.Caller{Owner: fuse.Owner{Uid: 99, Gid: 100}},
|
|
},
|
|
Oldnodeid: oldNodeId,
|
|
}, name, out)
|
|
if status != fuse.OK {
|
|
t.Fatalf("Link status = %v, want OK", status)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Entries written outside a mount (S3 API, WebDAV, a direct filer call) persist
|
|
// Attributes.Inode == 0, because the inode is a mount-runtime number that only
|
|
// lives in inodeToPath. Replying to LINK with NodeId 0 makes the kernel fail the
|
|
// call with EIO (invalid_nodeid), which is what
|
|
// https://github.com/seaweedfs/seaweedfs/issues/8404 reports. The reply has to
|
|
// name the node id the kernel already holds for the source.
|
|
func TestLinkReportsKernelNodeIdWhenEntryHasNoPersistedInode(t *testing.T) {
|
|
wfs, _ := newCreateTestWFS(t)
|
|
|
|
sourcePath, sourceInode := linkTestSource(t, wfs, "s3-uploaded.txt", 0)
|
|
|
|
out := doLink(t, wfs, sourceInode, "hardlink.txt")
|
|
|
|
if out.NodeId == 0 {
|
|
t.Fatal("Link replied with NodeId 0; the kernel rejects that with EIO")
|
|
}
|
|
if out.NodeId != sourceInode {
|
|
t.Fatalf("Link NodeId = %d, want the source node id %d", out.NodeId, sourceInode)
|
|
}
|
|
if out.Attr.Ino != sourceInode {
|
|
t.Fatalf("Link Attr.Ino = %d, want %d", out.Attr.Ino, sourceInode)
|
|
}
|
|
if out.Attr.Nlink != 2 {
|
|
t.Fatalf("Link Attr.Nlink = %d, want 2", out.Attr.Nlink)
|
|
}
|
|
|
|
// The new name must resolve to the same inode, or a later Lookup on it
|
|
// hands the kernel node id 0 and the link reads back as missing.
|
|
linkPath := util.FullPath("/hardlink.txt")
|
|
linkInode, found := wfs.inodeToPath.GetInode(linkPath)
|
|
if !found {
|
|
t.Fatal("new link was not registered in inodeToPath")
|
|
}
|
|
if linkInode != sourceInode {
|
|
t.Fatalf("new link inode = %d, want the source inode %d", linkInode, sourceInode)
|
|
}
|
|
if wfs.inodeToPath.HasInode(0) {
|
|
t.Fatal("inode 0 was registered in inodeToPath")
|
|
}
|
|
|
|
paths := wfs.inodeToPath.GetAllPaths(sourceInode)
|
|
if len(paths) != 2 {
|
|
t.Fatalf("inode %d has paths %v, want both %s and %s", sourceInode, paths, sourcePath, linkPath)
|
|
}
|
|
|
|
// The source still resolves, and it now carries a hard link id.
|
|
if _, status := wfs.inodeToPath.GetPath(sourceInode); status != fuse.OK {
|
|
t.Fatalf("GetPath(%d) = %v, want OK", sourceInode, status)
|
|
}
|
|
linked, _, status := wfs.maybeLoadEntry(context.Background(), sourcePath)
|
|
if status != fuse.OK {
|
|
t.Fatalf("reload source: %v", status)
|
|
}
|
|
if len(linked.HardLinkId) == 0 {
|
|
t.Fatal("source entry was not converted to hard link mode")
|
|
}
|
|
if linked.HardLinkCounter != 2 {
|
|
t.Fatalf("source HardLinkCounter = %d, want 2", linked.HardLinkCounter)
|
|
}
|
|
}
|
|
|
|
// A source created through the mount does carry a persisted inode. The reply
|
|
// must keep naming the node id the kernel holds, which for this case is the
|
|
// same number.
|
|
func TestLinkReportsKernelNodeIdWhenEntryHasPersistedInode(t *testing.T) {
|
|
wfs, _ := newCreateTestWFS(t)
|
|
|
|
_, sourceInode := linkTestSource(t, wfs, "mount-created.txt", 8404)
|
|
if sourceInode != 8404 {
|
|
t.Fatalf("source node id = %d, want the persisted inode 8404", sourceInode)
|
|
}
|
|
|
|
out := doLink(t, wfs, sourceInode, "hardlink.txt")
|
|
|
|
if out.NodeId != sourceInode {
|
|
t.Fatalf("Link NodeId = %d, want %d", out.NodeId, sourceInode)
|
|
}
|
|
if out.Attr.Ino != sourceInode {
|
|
t.Fatalf("Link Attr.Ino = %d, want %d", out.Attr.Ino, sourceInode)
|
|
}
|
|
if linkInode, found := wfs.inodeToPath.GetInode(util.FullPath("/hardlink.txt")); !found || linkInode != sourceInode {
|
|
t.Fatalf("new link inode = %d (found=%v), want %d", linkInode, found, sourceInode)
|
|
}
|
|
}
|
|
|
|
// cachedLink returns what the mount would serve for path out of its own meta
|
|
// cache, which is what a stat on that name reads before the kernel's attr
|
|
// cache expires.
|
|
func cachedLink(t *testing.T, wfs *WFS, path util.FullPath) *filer.Entry {
|
|
t.Helper()
|
|
|
|
entry, _, err := wfs.metaCache.FindEntry(context.Background(), path)
|
|
if err != nil {
|
|
t.Fatalf("FindEntry(%s): %v", path, err)
|
|
}
|
|
if entry == nil {
|
|
t.Fatalf("FindEntry(%s): not cached", path)
|
|
}
|
|
return entry
|
|
}
|
|
|
|
// A third link is the first case that reaches the body of
|
|
// syncHardLinkSiblings: with two links the source alias and the name just
|
|
// created are both in skipPaths, so the loop iterates over nothing. Three
|
|
// links leave one name that no other part of Link() writes and that only the
|
|
// sibling sync visits.
|
|
//
|
|
// The source is written outside a mount, so it persists Attributes.Inode 0,
|
|
// the number Link() used to key the sync by. GetAllPaths(0) yields nothing,
|
|
// which is what the second half of this test pins down: the sync has to be
|
|
// keyed by the kernel node id, the number inodeToPath is indexed by.
|
|
func TestLinkSyncsHardLinkSiblingsOnThirdLink(t *testing.T) {
|
|
wfs, _ := newCreateTestWFS(t)
|
|
|
|
sourcePath, sourceInode := linkTestSource(t, wfs, "s3-uploaded.txt", 0)
|
|
|
|
firstLinkPath := util.FullPath("/hardlink-1.txt")
|
|
doLink(t, wfs, sourceInode, "hardlink-1.txt")
|
|
|
|
// Two links so far: nothing has exercised the sibling loop yet, Link()
|
|
// wrote both of these names itself.
|
|
if counter := cachedLink(t, wfs, firstLinkPath).HardLinkCounter; counter != 2 {
|
|
t.Fatalf("after the first link, %s HardLinkCounter = %d, want 2", firstLinkPath, counter)
|
|
}
|
|
|
|
// Link() resolves its source through GetPath and skips that path plus the
|
|
// name it is about to create. Whatever is left over is the sibling loop's
|
|
// work.
|
|
skippedSource, status := wfs.inodeToPath.GetPath(sourceInode)
|
|
if status != fuse.OK {
|
|
t.Fatalf("GetPath(%d) = %v, want OK", sourceInode, status)
|
|
}
|
|
secondLinkPath := util.FullPath("/hardlink-2.txt")
|
|
|
|
doLink(t, wfs, sourceInode, "hardlink-2.txt")
|
|
|
|
paths := wfs.inodeToPath.GetAllPaths(sourceInode)
|
|
if len(paths) != 3 {
|
|
t.Fatalf("inode %d has paths %v, want %s, %s and %s",
|
|
sourceInode, paths, sourcePath, firstLinkPath, secondLinkPath)
|
|
}
|
|
|
|
// Guard against this test covering nothing: at least one name has to fall
|
|
// outside skipPaths, or the sibling loop is a no-op again and everything
|
|
// below only re-checks Link()'s own two writes.
|
|
var siblings []util.FullPath
|
|
for _, p := range paths {
|
|
if p == skippedSource || p == secondLinkPath {
|
|
continue
|
|
}
|
|
siblings = append(siblings, p)
|
|
}
|
|
if len(siblings) == 0 {
|
|
t.Fatalf("every path in %v is a skipPath (%s, %s); the sibling loop has no work to do",
|
|
paths, skippedSource, secondLinkPath)
|
|
}
|
|
|
|
// Every name of the file reports three links, the sibling included. A stale
|
|
// counter here is the pjdfstest link/00.t failure: stat on an older link
|
|
// still says nlink 2.
|
|
for _, p := range paths {
|
|
if counter := cachedLink(t, wfs, p).HardLinkCounter; counter != 3 {
|
|
t.Errorf("%s HardLinkCounter = %d, want 3", p, counter)
|
|
}
|
|
}
|
|
|
|
// Which inode the sync is keyed by, driven directly. Going through Link()
|
|
// cannot tell the two keys apart: the meta cache keeps one blob per hard
|
|
// link id (FilerStoreWrapper.setHardLink/maybeReadHardLink), so a read of
|
|
// any sibling returns the attributes of the last write to any of them,
|
|
// synced or not. Feeding the loop an authoritative counter that no other
|
|
// write has stored is what makes its work visible.
|
|
authoritative, _, status := wfs.maybeLoadEntry(context.Background(), sourcePath)
|
|
if status != fuse.OK {
|
|
t.Fatalf("reload source: %v", status)
|
|
}
|
|
if authoritative.Attributes.Inode != 0 {
|
|
t.Fatalf("source Attributes.Inode = %d, want 0: an entry written outside a mount carries no inode",
|
|
authoritative.Attributes.Inode)
|
|
}
|
|
authoritative.HardLinkCounter = 4
|
|
|
|
wfs.syncHardLinkSiblings(context.Background(), authoritative.Attributes.Inode, authoritative, 0, skippedSource, secondLinkPath)
|
|
for _, p := range siblings {
|
|
if counter := cachedLink(t, wfs, p).HardLinkCounter; counter != 3 {
|
|
t.Errorf("keyed by the persisted inode the sync reached %s (counter %d), which it cannot: "+
|
|
"GetAllPaths(%d) has no paths", p, counter, authoritative.Attributes.Inode)
|
|
}
|
|
}
|
|
|
|
wfs.syncHardLinkSiblings(context.Background(), sourceInode, authoritative, 0, skippedSource, secondLinkPath)
|
|
for _, p := range siblings {
|
|
if counter := cachedLink(t, wfs, p).HardLinkCounter; counter != 4 {
|
|
t.Errorf("%s HardLinkCounter = %d after the sibling sync, want 4", p, counter)
|
|
}
|
|
}
|
|
}
|
|
|
|
// When the sibling refresh cannot reach the filer, the fallback patches the
|
|
// cached counter without proving the content reflects the mutation — so it
|
|
// keeps the version the sibling already held: claiming the mutation's
|
|
// position would fence an event still in flight for the sibling, while
|
|
// claiming nothing would let a replayed older delete remove the live link.
|
|
// The test filer answers every LookupDirectoryEntry with Unimplemented, so
|
|
// the sync always lands on that fallback.
|
|
func TestSyncHardLinkSiblingFetchFailureKeepsVersion(t *testing.T) {
|
|
wfs, _ := newCreateTestWFS(t)
|
|
|
|
hardLinkId := filer.HardLinkId([]byte{9, 9, 9, 9})
|
|
sibPath := util.FullPath("/sib.txt")
|
|
if err := wfs.metaCache.InsertEntry(context.Background(), &filer.Entry{
|
|
FullPath: sibPath,
|
|
Attr: filer.Attr{Crtime: time.Unix(1, 0), Mtime: time.Unix(1, 0), Mode: 0o644, FileSize: 12, Uid: 99, Gid: 100},
|
|
HardLinkId: hardLinkId,
|
|
HardLinkCounter: 2,
|
|
}, 7000); err != nil {
|
|
t.Fatalf("seed sibling: %v", err)
|
|
}
|
|
inode := wfs.inodeToPath.Lookup(sibPath, 1, false, false, 77, true)
|
|
if inode == 0 {
|
|
t.Fatal("Lookup handed out node id 0")
|
|
}
|
|
|
|
authoritative := &filer_pb.Entry{
|
|
HardLinkId: hardLinkId,
|
|
HardLinkCounter: 4,
|
|
Attributes: &filer_pb.FuseAttributes{Ctime: 8},
|
|
}
|
|
wfs.syncHardLinkSiblings(context.Background(), inode, authoritative, 9000)
|
|
|
|
if counter := cachedLink(t, wfs, sibPath).HardLinkCounter; counter != 4 {
|
|
t.Fatalf("sibling HardLinkCounter = %d after the fallback patch, want 4", counter)
|
|
}
|
|
|
|
// The patch claimed only the version the sibling already held (7000), so
|
|
// a delayed delete replayed from before that point is fenced out instead
|
|
// of removing the live link.
|
|
if err := wfs.metaCache.ApplyMetadataResponse(context.Background(), &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
OldEntry: &filer_pb.Entry{Name: "sib.txt"},
|
|
},
|
|
TsNs: 6000,
|
|
}, meta_cache.SubscriberMetadataResponseApplyOptions); err != nil {
|
|
t.Fatalf("apply delayed delete: %v", err)
|
|
}
|
|
if sib := cachedLink(t, wfs, sibPath); sib == nil {
|
|
t.Fatal("replayed older delete removed the live link")
|
|
}
|
|
|
|
// The mutation committed at 9000 but the patch proves nothing about the
|
|
// sibling's content: an event at 8000 still in flight must land.
|
|
if err := wfs.metaCache.ApplyMetadataResponse(context.Background(), &filer_pb.SubscribeMetadataResponse{
|
|
Directory: "/",
|
|
EventNotification: &filer_pb.EventNotification{
|
|
NewEntry: &filer_pb.Entry{
|
|
Name: "sib.txt",
|
|
HardLinkId: hardLinkId,
|
|
HardLinkCounter: 2,
|
|
Attributes: &filer_pb.FuseAttributes{Crtime: 1, Mtime: 5, FileMode: 0o644, FileSize: 99, Uid: 99, Gid: 100},
|
|
},
|
|
},
|
|
TsNs: 8000,
|
|
}, meta_cache.SubscriberMetadataResponseApplyOptions); err != nil {
|
|
t.Fatalf("apply pending event: %v", err)
|
|
}
|
|
if sib := cachedLink(t, wfs, sibPath); sib.FileSize != 99 {
|
|
t.Errorf("pending event was fenced by the patched version: FileSize = %d, want 99", sib.FileSize)
|
|
}
|
|
}
|