mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-21 14:46:58 +00:00
* 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.
270 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|