Files
seaweedfs/weed/mount/weedfs_dir_lookup_cache_test.go
T
Chris LuandDevin bd0bc06df7 mount: unwind metadata lookups on FUSE interrupt (#11717)
* mount: propagate request context through metadata lookups

The FUSE interrupt channel never reached the filer RPC: lookupEntry ran
its LookupEntry under context.Background(), so a stalled filer parked
ops like ListXAttr forever even after the kernel gave up on them.

Thread a context from each op's cancel channel through
maybeReadEntry, maybeLoadEntry, lookupEntry, ensureDirectoryVisited and
waitForPendingAsyncFlush; the existing withFilerClient attempt bound
turns it into both an interrupt and a deadline. A caller cancellation
now surfaces as EINTR instead of waiting out the RPC.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: reuse ctxFromCancel for the read path's interrupt bridge

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: regression tests for canceled and stalled filer lookups

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: thread ctx through AcquireHandle

Open, Create and Mknod all carry the request's cancel channel; let it
bound the pending-flush wait and metadata load inside AcquireHandle too.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: keep flush waits and write sequences uninterruptible

The cancel-derived context must only cover metadata reads: an
interrupted Unlink or Rename that skips the pending-flush wait can
race the async write back into existence, and Link/Rename mutations
that run under a canceled context abort mid-sequence, strand the
rollback, and leave wrong hard-link counts. Restore the blocking flush
wait and run each op's write phase on context.Background(). Rmdir and
Unlink also propagate EINTR from the parent lookup instead of
proceeding past a permission check the interrupt prevented.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: interrupt shared directory builds and directory reads

Waiters behind another request's singleflight directory build blocked
until it finished even when interrupted; switch the wait to DoChan so
the request context can abandon it. ReadDir paths now carry the
request context through EnsureListingFresh, cache listings and direct
reads, and map its cancellation to EINTR rather than EIO.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: assert exact statuses in filer-stall regression tests

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: let read-side flush waits release on interrupt

waitForPendingAsyncFlush reports whether the flush actually finished:
lookupEntry and AcquireHandle return EINTR when the request is
abandoned mid-wait, while Unlink and Rename keep waiting on
context.Background() so the delete/rename still sees the flushed
state.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: propagate interrupt through remaining lookup checks

Rename's source and destination permission lookups now return EINTR
on interrupt instead of skipping checkStickyBit or masking an
existing target, and the readdir preload visit maps cancellation to
EINTR like the other branches.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* mount: fail closed on errored rename prerequisite lookups

* mount: stop rmdir on interrupted target lookup, fail closed on dir lookup errors

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-12 09:42:52 +08:00

334 lines
11 KiB
Go

package mount
import (
"context"
"errors"
"fmt"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"github.com/seaweedfs/go-fuse/v2/fuse"
"github.com/seaweedfs/seaweedfs/weed/mount/meta_cache"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func newLookupCacheTestWFS(t *testing.T, ttlSec int) *WFS {
t.Helper()
uidGidMapper, err := meta_cache.NewUidGidMapper("", "")
if err != nil {
t.Fatalf("create uid/gid mapper: %v", err)
}
root := util.FullPath("/")
wfs := &WFS{
signature: 1,
inodeToPath: NewInodeToPath(root, ttlSec),
fhMap: NewFileHandleToInode(),
fhLockTable: util.NewLockTable[FileHandleId](),
hardLinkLockTable: util.NewLockTable[string](),
option: &Option{
ChunkSizeLimit: 1024,
ConcurrentReaders: 1,
VolumeServerAccess: "filerProxy",
FilerAddresses: []pb.ServerAddress{
pb.NewServerAddressWithGrpcPort("127.0.0.1:1", 1),
},
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
UidGidMapper: uidGidMapper,
},
}
wfs.metaCache = meta_cache.NewMetaCache(
filepath.Join(t.TempDir(), "meta"),
uidGidMapper, root, false,
func(path util.FullPath) { wfs.inodeToPath.MarkChildrenCached(path) },
func(path util.FullPath) bool { return wfs.inodeToPath.IsChildrenCached(path) },
wfs.onEntryInvalidation, nil,
)
t.Cleanup(wfs.metaCache.Shutdown)
return wfs
}
type lookupCacheTestFiler struct {
filer_pb.UnimplementedSeaweedFilerServer
entries []*filer_pb.Entry
snapshotTsNs int64
listErr error
listCalls atomic.Int32
lookupCalls atomic.Int32
mu sync.Mutex
listGate chan struct{}
startedCh chan struct{}
startedOnce sync.Once
}
func (s *lookupCacheTestFiler) setListGate(gate, started chan struct{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.listGate = gate
s.startedCh = started
s.startedOnce = sync.Once{}
}
func (s *lookupCacheTestFiler) signalStarted() {
s.startedOnce.Do(func() {
if s.startedCh != nil {
close(s.startedCh)
}
})
}
func (s *lookupCacheTestFiler) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
s.lookupCalls.Add(1)
return &filer_pb.LookupDirectoryEntryResponse{
Entry: &filer_pb.Entry{
Name: req.Name,
Attributes: &filer_pb.FuseAttributes{FileSize: 123, FileMode: 0100644},
},
}, nil
}
func (s *lookupCacheTestFiler) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
s.listCalls.Add(1)
s.mu.Lock()
gate := s.listGate
s.mu.Unlock()
if gate != nil {
s.signalStarted()
<-gate
}
if s.listErr != nil {
return s.listErr
}
for _, e := range s.entries {
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: e}); err != nil {
return err
}
}
if s.snapshotTsNs != 0 {
stream.SetTrailer(metadata.Pairs(filer_pb.ListSnapshotTsNsTrailerKey, strconv.FormatInt(s.snapshotTsNs, 10)))
}
return nil
}
// expireDirCache marks a cached directory's TTL as elapsed without invalidating
// it, reproducing the state the kernel leaves the mount in when it still holds
// the directory listing (FOPEN_CACHE_DIR | FOPEN_KEEP_CACHE) past cacheMetaTtlSec.
func expireDirCache(t *testing.T, itp *InodeToPath, dir util.FullPath) {
t.Helper()
itp.Lock()
defer itp.Unlock()
d := itp.dirStateOf(dir)
if d == nil {
t.Fatalf("expireDirCache: no dirState for %s", dir)
}
if !d.isChildrenCached {
t.Fatalf("expireDirCache: %s not cached", dir)
}
d.cachedExpiresTime = time.Now().Add(-time.Second)
}
// TestLookupRebuildsExpiredDirectoryCache reproduces issue #11262: after the
// metadata cache TTL elapses the kernel can still serve directory listings
// from its page cache, so ReadDir never runs and EnsureVisited is not called.
// Metadata-heavy listings (e.g. `ls --color`) then reach Lookup, which must
// rebuild the directory cache once instead of issuing one LookupEntry RPC
// per entry.
func TestLookupRebuildsExpiredDirectoryCache(t *testing.T) {
wfs := newLookupCacheTestWFS(t, 60)
dir := util.FullPath("/dir")
wfs.inodeToPath.Lookup(dir, time.Now().Unix(), true, false, 0, true)
fake := &lookupCacheTestFiler{
entries: []*filer_pb.Entry{
{Name: "a", Attributes: &filer_pb.FuseAttributes{FileSize: 1, FileMode: 0100644}},
{Name: "b", Attributes: &filer_pb.FuseAttributes{FileSize: 2, FileMode: 0100644}},
{Name: "c", Attributes: &filer_pb.FuseAttributes{FileSize: 3, FileMode: 0100644}},
},
snapshotTsNs: 5000,
}
startFakeFiler(t, wfs, fake)
if err := meta_cache.EnsureVisited(context.Background(), wfs.metaCache, wfs, dir, 0); err != nil {
t.Fatalf("EnsureVisited: %v", err)
}
if !wfs.inodeToPath.IsChildrenCached(dir) {
t.Fatal("directory should be cached after EnsureVisited")
}
expireDirCache(t, wfs.inodeToPath, dir)
if wfs.inodeToPath.IsChildrenCached(dir) {
t.Fatal("directory cache should be expired")
}
listCallsBefore := fake.listCalls.Load()
lookupCallsBefore := fake.lookupCalls.Load()
for _, name := range []string{"a", "b", "c"} {
entry, _, status := wfs.lookupEntry(context.Background(), dir.Child(name))
if status != fuse.OK {
t.Fatalf("lookupEntry %s: %v", name, status)
}
if entry == nil || entry.Name() != name {
t.Fatalf("lookupEntry %s: got %v", name, entry)
}
}
listCalls := fake.listCalls.Load() - listCallsBefore
lookupCalls := fake.lookupCalls.Load() - lookupCallsBefore
if lookupCalls != 0 {
t.Errorf("LookupDirectoryEntry RPCs = %d, want 0 (expired dir cache should be rebuilt, not read per-entry)", lookupCalls)
}
if listCalls != 1 {
t.Errorf("ListEntries RPCs = %d, want 1 (expired dir cache should be rebuilt once)", listCalls)
}
}
// TestLookupRebuildDeduplicatesConcurrentRebuilds exercises the singleflight in
// EnsureVisited: concurrent lookups into the same expired directory share one
// rebuild rather than each issuing its own ListEntries.
func TestLookupRebuildDeduplicatesConcurrentRebuilds(t *testing.T) {
wfs := newLookupCacheTestWFS(t, 60)
dir := util.FullPath("/dir")
wfs.inodeToPath.Lookup(dir, time.Now().Unix(), true, false, 0, true)
fake := &lookupCacheTestFiler{
entries: []*filer_pb.Entry{
{Name: "a", Attributes: &filer_pb.FuseAttributes{FileSize: 1, FileMode: 0100644}},
{Name: "b", Attributes: &filer_pb.FuseAttributes{FileSize: 2, FileMode: 0100644}},
{Name: "c", Attributes: &filer_pb.FuseAttributes{FileSize: 3, FileMode: 0100644}},
},
snapshotTsNs: 5000,
}
startFakeFiler(t, wfs, fake)
if err := meta_cache.EnsureVisited(context.Background(), wfs.metaCache, wfs, dir, 0); err != nil {
t.Fatalf("EnsureVisited: %v", err)
}
expireDirCache(t, wfs.inodeToPath, dir)
started := make(chan struct{})
gate := make(chan struct{})
fake.setListGate(gate, started)
listCallsBefore := fake.listCalls.Load()
names := []string{"a", "b", "c"}
var wg sync.WaitGroup
errs := make([]error, len(names))
for i, name := range names {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
if _, _, status := wfs.lookupEntry(context.Background(), dir.Child(name)); status != fuse.OK {
errs[i] = fmt.Errorf("lookupEntry %s: %v", name, status)
}
}(i, name)
}
<-started
// Hold the rebuild so the other lookups reach the singleflight while it
// is in flight, exercising the deduplication rather than serializing.
time.Sleep(50 * time.Millisecond)
close(gate)
wg.Wait()
for _, err := range errs {
if err != nil {
t.Fatal(err)
}
}
if got := fake.listCalls.Load() - listCallsBefore; got != 1 {
t.Errorf("rebuild ListEntries RPCs = %d, want 1 (concurrent lookups should share one rebuild)", got)
}
if got := fake.lookupCalls.Load(); got != 0 {
t.Errorf("LookupDirectoryEntry RPCs = %d, want 0", got)
}
}
// TestLookupRebuildCooldownAfterFailure guards the failure path: a failed
// rebuild records the attempt so an immediate retry is suppressed (per-entry
// RPC fallback), and once the cooldown elapses and the filer recovers a later
// lookup rebuilds the cache again.
func TestLookupRebuildCooldownAfterFailure(t *testing.T) {
wfs := newLookupCacheTestWFS(t, 60)
dir := util.FullPath("/dir")
wfs.inodeToPath.Lookup(dir, time.Now().Unix(), true, false, 0, true)
fake := &lookupCacheTestFiler{
entries: []*filer_pb.Entry{
{Name: "a", Attributes: &filer_pb.FuseAttributes{FileSize: 1, FileMode: 0100644}},
},
snapshotTsNs: 5000,
}
startFakeFiler(t, wfs, fake)
if err := meta_cache.EnsureVisited(context.Background(), wfs.metaCache, wfs, dir, 0); err != nil {
t.Fatalf("EnsureVisited: %v", err)
}
expireDirCache(t, wfs.inodeToPath, dir)
// First lookup: rebuild fails, falls through to the per-entry RPC.
fake.listErr = errors.New("persistent listing failure")
listCallsBefore := fake.listCalls.Load()
lookupCallsBefore := fake.lookupCalls.Load()
if entry, _, status := wfs.lookupEntry(context.Background(), dir.Child("a")); status != fuse.OK || entry == nil || entry.Name() != "a" {
t.Fatalf("lookupEntry a after failed rebuild: status=%v entry=%v", status, entry)
}
if got := fake.lookupCalls.Load() - lookupCallsBefore; got != 1 {
t.Errorf("first lookup LookupDirectoryEntry RPCs = %d, want 1 (fall through to per-entry RPC)", got)
}
if got := fake.listCalls.Load() - listCallsBefore; got == 0 {
t.Error("first lookup should have attempted a rebuild")
}
// Immediate retry: cooldown suppresses the rebuild, falls through again.
listCallsBefore = fake.listCalls.Load()
lookupCallsBefore = fake.lookupCalls.Load()
if _, _, status := wfs.lookupEntry(context.Background(), dir.Child("a")); status != fuse.OK {
t.Fatalf("second lookupEntry a: %v", status)
}
if got := fake.listCalls.Load() - listCallsBefore; got != 0 {
t.Errorf("second lookup ListEntries RPCs = %d, want 0 (cooldown suppresses rebuild)", got)
}
if got := fake.lookupCalls.Load() - lookupCallsBefore; got != 1 {
t.Errorf("second lookup LookupDirectoryEntry RPCs = %d, want 1", got)
}
// Filer recovers and the cooldown elapses: the next lookup rebuilds.
fake.listErr = nil
advanceRebuildAttempt(t, wfs.inodeToPath, dir, expiredDirRebuildCooldown+time.Second)
listCallsBefore = fake.listCalls.Load()
lookupCallsBefore = fake.lookupCalls.Load()
if _, _, status := wfs.lookupEntry(context.Background(), dir.Child("a")); status != fuse.OK {
t.Fatalf("third lookupEntry a: %v", status)
}
if got := fake.listCalls.Load() - listCallsBefore; got != 1 {
t.Errorf("third lookup ListEntries RPCs = %d, want 1 (rebuild after cooldown)", got)
}
if got := fake.lookupCalls.Load() - lookupCallsBefore; got != 0 {
t.Errorf("third lookup LookupDirectoryEntry RPCs = %d, want 0 (served from rebuilt cache)", got)
}
}
// advanceRebuildAttempt moves a directory's last rebuild attempt back by d,
// simulating the cooldown having elapsed without sleeping.
func advanceRebuildAttempt(t *testing.T, itp *InodeToPath, dir util.FullPath, d time.Duration) {
t.Helper()
itp.Lock()
defer itp.Unlock()
state := itp.dirStateOf(dir)
if state == nil {
t.Fatalf("advanceRebuildAttempt: no dirState for %s", dir)
}
state.lastRebuildAttempt = time.Now().Add(-d)
}