Files
seaweedfs/weed/mount/winfsp/pathcache_test.go
T
Chris LuandGitHub 804111745a mount: discard a path-cache insert that raced a purge (#10842)
* mount: discard a path-cache insert that raced a purge

The Windows adapter's walk resolves a component with a Lookup RPC and inserts
the result holding no lock, so a purge can land in between - and what the walk
just resolved is then the very name the purge removed. Anything opening the
old path concurrently with a rename repopulates the cache with the vacated
name, which the next stat is served from for up to a second. The release path
already guards its equivalent insert; the walk had nothing.

The cache counts purges now. A resolve snapshots the generation before its
lookups and insert discards the entry when any purge ran in between, parking
the reference in the graveyard so the in-flight caller keeps a valid inode
either way.

Seen once in CI as TestRenameOverExisting failing with 'source survived the
rename': every SeaweedFS layer is synchronous with the rename, but a
background open of the source - an antivirus scan of the just-written file
fits - can requalify the stale name through this window. The assertion also
reports what stat returned now, and whether it persisted, so a recurrence
indicts a specific layer instead of reading as a mystery.

* mount: cover the path-cache discard by key, and let a discard rest

Review follow-ups. The generation was global, so any purge between a walk's
snapshot and its insert discarded the entry whatever its name - and an open
retries resolve-then-steal only four times before failing with EIO, so
sustained unrelated churn could fail opens of untouched paths. Purges are
remembered by key now and only one that covers the inserted name discards it;
past the remembered window the insert is discarded without a check, which only
costs a retry.

A discard that itself tripped the sweep also handed its own reference straight
to forget while the walker was still using the inode. The graveyard holds two
generations now, so an appended reference always survives the sweep of the
call that appended it - which the displaced-entry and purge paths needed too.

Also restores the original path-cache test suite this branch had overwritten
instead of extended, and rewords the semantics-test failure so it no longer
claims the source survived when stat returned a transient error.

* mount: take an open's reference directly instead of stealing it back

resolveAndSteal cached the final component only to steal it back, so an open
depended on that insert surviving whatever purges raced it - four attempts and
then EIO. The keyed purge window narrowed how often an insert is discarded,
but past the window the discard is blind again, so the cliff had only moved.

A cached entry is still stolen; anything else is now looked up directly, with
the caller owning the reference from the start. No retry loop, and no way for
churn - covered, unrelated or overflowing the window - to fail an open.

Also covers the whole-cache purge: purge of the root with prefix set clears
every entry, but the covers check tested for a '/'-prefixed key that a
normalised key never has, so it covered no in-flight insert at all.
2026-08-19 17:05:49 -07:00

270 lines
7.6 KiB
Go

package winfsp
import (
"fmt"
"sync"
"testing"
"time"
"github.com/seaweedfs/go-fuse/v2/fuse"
)
type forgetRecorder struct {
mu sync.Mutex
inodes []uint64
}
func (r *forgetRecorder) forget(inode uint64) {
r.mu.Lock()
defer r.mu.Unlock()
r.inodes = append(r.inodes, inode)
}
func (r *forgetRecorder) forgotten() []uint64 {
r.mu.Lock()
defer r.mu.Unlock()
return append([]uint64(nil), r.inodes...)
}
func TestPathCacheLookupAndExpiry(t *testing.T) {
rec := &forgetRecorder{}
c := newPathCache(20*time.Millisecond, rec.forget)
c.insert("a/b", 7, fuse.Attr{Ino: 7, Size: 42}, c.snapshot())
inode, attr, ok := c.lookup("a/b")
if !ok || inode != 7 || attr.Size != 42 {
t.Fatalf("lookup = %d %v %v, want 7 size 42 true", inode, attr.Size, ok)
}
time.Sleep(30 * time.Millisecond)
if _, _, ok := c.lookup("a/b"); ok {
t.Fatal("expired entry still served")
}
// The reference survives at least one sweep past expiry before it is
// forgotten: one insert moves it to the graveyard, the next returns it.
c.insert("x", 8, fuse.Attr{}, c.snapshot())
time.Sleep(30 * time.Millisecond)
c.insert("y", 9, fuse.Attr{}, c.snapshot())
found := false
for _, inode := range rec.forgotten() {
if inode == 7 {
found = true
}
}
if !found {
t.Fatalf("expired reference never forgotten; forgot %v", rec.forgotten())
}
}
func TestPathCacheStealTransfersOwnership(t *testing.T) {
rec := &forgetRecorder{}
c := newPathCache(20*time.Millisecond, rec.forget)
c.insert("a", 5, fuse.Attr{}, c.snapshot())
inode, ok := c.steal("a")
if !ok || inode != 5 {
t.Fatalf("steal = %d %v, want 5 true", inode, ok)
}
if _, ok := c.steal("a"); ok {
t.Fatal("second steal succeeded")
}
// Drive several sweeps; the stolen reference must never be forgotten.
for i := 0; i < 4; i++ {
time.Sleep(25 * time.Millisecond)
c.insert("churn", uint64(100+i), fuse.Attr{}, c.snapshot())
}
for _, inode := range rec.forgotten() {
if inode == 5 {
t.Fatal("stolen reference was forgotten by the cache")
}
}
}
func TestPathCacheReplaceReturnsOldReference(t *testing.T) {
rec := &forgetRecorder{}
c := newPathCache(20*time.Millisecond, rec.forget)
c.insert("a", 5, fuse.Attr{}, c.snapshot())
c.insert("a", 6, fuse.Attr{}, c.snapshot())
if inode, _, ok := c.lookup("a"); !ok || inode != 6 {
t.Fatalf("lookup after replace = %d %v, want 6 true", inode, ok)
}
for i := 0; i < 3; i++ {
time.Sleep(25 * time.Millisecond)
c.insert("churn", uint64(100+i), fuse.Attr{}, c.snapshot())
}
found := false
for _, inode := range rec.forgotten() {
if inode == 5 {
found = true
}
if inode == 6 && !found {
t.Fatal("live reference forgotten before the replaced one")
}
}
if !found {
t.Fatalf("replaced reference never forgotten; forgot %v", rec.forgotten())
}
}
func TestPathCachePurgePrefix(t *testing.T) {
rec := &forgetRecorder{}
c := newPathCache(time.Minute, rec.forget)
c.insert("dir", 2, fuse.Attr{}, c.snapshot())
c.insert("dir/a", 3, fuse.Attr{}, c.snapshot())
c.insert("dir/a/b", 4, fuse.Attr{}, c.snapshot())
c.insert("dirt", 5, fuse.Attr{}, c.snapshot())
c.purge("dir", true)
if _, _, ok := c.lookup("dir"); ok {
t.Fatal("purged key still cached")
}
if _, _, ok := c.lookup("dir/a/b"); ok {
t.Fatal("purged subtree still cached")
}
// A sibling that only shares the name as a string prefix stays.
if _, _, ok := c.lookup("dirt"); !ok {
t.Fatal("sibling was purged with the subtree")
}
}
func TestPathCacheRootNeverCached(t *testing.T) {
rec := &forgetRecorder{}
c := newPathCache(time.Minute, rec.forget)
c.insert("", 9, fuse.Attr{}, c.snapshot())
if _, _, ok := c.lookup(""); ok {
t.Fatal("root was cached")
}
if got := rec.forgotten(); len(got) != 1 || got[0] != 9 {
t.Fatalf("root reference not returned immediately: %v", got)
}
}
func TestCacheKey(t *testing.T) {
for path, want := range map[string]string{
`/a/b`: "a/b",
`\a\b`: "a/b",
`a//b/`: "a/b",
`/`: "",
``: "",
`/a/./b`: "a/b",
} {
if got := cacheKey(path); got != want {
t.Errorf("cacheKey(%q) = %q, want %q", path, got, want)
}
}
}
// A resolve runs its Lookup outside the cache lock, so a purge can land
// between the RPC and the insert. An insert a purge covered must then be
// discarded: it carries exactly the name the purge removed, and caching it
// would serve a deleted entry for a full ttl. This is how a rename's source
// briefly came back from the dead on the Windows mount.
func TestPathCacheInsertAfterCoveringPurgeIsDiscarded(t *testing.T) {
c := newPathCache(time.Minute, func(uint64) {})
gen := c.snapshot()
c.purge("dir/src", false)
c.insert("dir/src", 42, fuse.Attr{}, gen)
if _, _, ok := c.lookup("dir/src"); ok {
t.Fatal("purged name served from an insert that started before the purge")
}
}
// A purge of a directory covers the resolves in flight under it.
func TestPathCacheInsertUnderPurgedPrefixIsDiscarded(t *testing.T) {
c := newPathCache(time.Minute, func(uint64) {})
gen := c.snapshot()
c.purge("dir", true)
c.insert("dir/child", 7, fuse.Attr{}, gen)
if _, _, ok := c.lookup("dir/child"); ok {
t.Fatal("purged prefix served a child from a stale insert")
}
}
// A purge of something else entirely must not discard the insert: an open
// retries resolve-then-steal only a few times before giving up with EIO, so
// unrelated churn starving every insert would fail opens of untouched paths.
func TestPathCacheUnrelatedPurgeDoesNotDiscard(t *testing.T) {
c := newPathCache(time.Minute, func(uint64) {})
gen := c.snapshot()
c.purge("elsewhere", true)
c.purge("dir/srcling", false)
c.insert("dir/src", 9, fuse.Attr{Size: 11}, gen)
inode, attr, ok := c.lookup("dir/src")
if !ok || inode != 9 || attr.Size != 11 {
t.Fatalf("lookup = %d,%d,%v, want the inserted entry", inode, attr.Size, ok)
}
}
// Purges beyond the remembered window cannot be checked by key, so the insert
// is discarded rather than trusted.
func TestPathCacheInsertOlderThanPurgeWindowIsDiscarded(t *testing.T) {
c := newPathCache(time.Minute, func(uint64) {})
gen := c.snapshot()
for i := 0; i <= maxRecentPurges; i++ {
c.purge(fmt.Sprintf("unrelated/%d", i), false)
}
c.insert("dir/src", 3, fuse.Attr{}, gen)
if _, _, ok := c.lookup("dir/src"); ok {
t.Fatal("insert older than the purge window was trusted")
}
}
// The discarded insert still owns a lookup reference, which has to come back
// through the graveyard rather than leak - and not in the same call: the
// walker is still using the inode.
func TestPathCacheDiscardedInsertRestsBeforeForget(t *testing.T) {
rec := &forgetRecorder{}
c := newPathCache(10*time.Millisecond, rec.forget)
gen := c.snapshot()
c.purge("dir/src", false)
// Make a sweep due, so the discarding insert itself sweeps: the reference
// it just parked must not come back out of that same call.
time.Sleep(15 * time.Millisecond)
c.insert("dir/src", 42, fuse.Attr{}, gen)
for _, inode := range rec.forgotten() {
if inode == 42 {
t.Fatal("discarded insert forgotten in the same call; the walker still holds it")
}
}
for i := 0; i < 3; i++ {
time.Sleep(15 * time.Millisecond)
c.purge("churn", false)
}
found := false
for _, inode := range rec.forgotten() {
if inode == 42 {
found = true
}
}
if !found {
t.Fatalf("discarded insert leaked its lookup reference; forgot %v", rec.forgotten())
}
}
// A purge of the whole cache - the root, prefix set - covers every in-flight
// insert, exactly as it removed every entry.
func TestPathCacheRootPurgeCoversEveryInsert(t *testing.T) {
c := newPathCache(time.Minute, func(uint64) {})
gen := c.snapshot()
c.purge("", true)
c.insert("dir/src", 42, fuse.Attr{}, gen)
if _, _, ok := c.lookup("dir/src"); ok {
t.Fatal("root purge did not cover an in-flight insert")
}
}