mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-21 22:56:55 +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.
234 lines
7.1 KiB
Go
234 lines
7.1 KiB
Go
package winfsp
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/go-fuse/v2/fuse"
|
|
)
|
|
|
|
// pathCache stands in for the dentry and attribute caches the kernel provides
|
|
// on the unix mounts. WinFsp addresses every operation by path, so without it
|
|
// each operation walks the whole path through Lookup again, and in a directory
|
|
// the filer has not listed yet every one of those lookups is a filer round
|
|
// trip.
|
|
//
|
|
// The cache owns one lookup reference per entry, the way the kernel holds one
|
|
// until it sends FORGET. An evicted reference sits out one sweep in the
|
|
// graveyard before it is returned, so an operation that resolved just before
|
|
// the eviction is not left holding a reclaimed inode.
|
|
type pathCache struct {
|
|
ttl time.Duration
|
|
forget func(inode uint64)
|
|
|
|
mu sync.Mutex
|
|
entries map[string]*pathCacheEntry
|
|
// Two graveyard generations: an appended reference always survives the
|
|
// sweep of the call that appended it, so a caller still using the inode -
|
|
// a walk mid-resolution, an operation that looked it up just before - is
|
|
// never holding a reference the same call just returned.
|
|
graveyard []uint64
|
|
prevGraveyard []uint64
|
|
lastSweep time.Time
|
|
// gen counts purges. A resolve holds no lock across its Lookup RPC, so a
|
|
// purge can run between the RPC and the insert; the insert then carries
|
|
// exactly the name the purge removed and has to be discarded. The recent
|
|
// purges are kept by key so only a purge that covers the inserted name
|
|
// discards it: unrelated churn must not starve resolveAndSteal into EIO.
|
|
gen uint64
|
|
recentPurges []purgeRecord
|
|
}
|
|
|
|
type purgeRecord struct {
|
|
key string
|
|
prefix bool
|
|
}
|
|
|
|
// maxRecentPurges bounds the purges remembered for the covers check. A resolve
|
|
// older than the window is discarded without one, which only costs a retry.
|
|
const maxRecentPurges = 128
|
|
|
|
func (r purgeRecord) covers(key string) bool {
|
|
if r.key == key {
|
|
return true
|
|
}
|
|
if !r.prefix {
|
|
return false
|
|
}
|
|
// An empty key with prefix is the whole cache: purge clears every entry
|
|
// for it, so it has to cover every in-flight insert too.
|
|
return r.key == "" || strings.HasPrefix(key, r.key+"/")
|
|
}
|
|
|
|
// purgedSince reports whether any purge after gen covers key.
|
|
func (c *pathCache) purgedSince(gen uint64, key string) bool {
|
|
dropped := c.gen - gen
|
|
if dropped == 0 {
|
|
return false
|
|
}
|
|
if dropped > uint64(len(c.recentPurges)) {
|
|
return true
|
|
}
|
|
for _, r := range c.recentPurges[uint64(len(c.recentPurges))-dropped:] {
|
|
if r.covers(key) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type pathCacheEntry struct {
|
|
inode uint64
|
|
attr fuse.Attr
|
|
expires time.Time
|
|
}
|
|
|
|
// maxCachedPaths bounds the references parked here. Overflow clears the whole
|
|
// cache rather than tracking recency: entries expire within ttl anyway, so
|
|
// exact eviction order buys nothing.
|
|
const maxCachedPaths = 64 << 10
|
|
|
|
func newPathCache(ttl time.Duration, forget func(inode uint64)) *pathCache {
|
|
return &pathCache{
|
|
ttl: ttl,
|
|
forget: forget,
|
|
entries: map[string]*pathCacheEntry{},
|
|
lastSweep: time.Now(),
|
|
}
|
|
}
|
|
|
|
// cacheKey canonicalises a WinFsp path. The root maps to "", which is never
|
|
// cached: its inode is fixed and holds no reference.
|
|
func cacheKey(path string) string {
|
|
return strings.Join(splitPath(path), "/")
|
|
}
|
|
|
|
// lookup reports the cached inode and attributes for key. The entry stays in
|
|
// the cache; the inode remains valid at least one sweep past its expiry.
|
|
func (c *pathCache) lookup(key string) (inode uint64, attr fuse.Attr, ok bool) {
|
|
if key == "" {
|
|
return 0, fuse.Attr{}, false
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
entry, found := c.entries[key]
|
|
if !found || time.Now().After(entry.expires) {
|
|
return 0, fuse.Attr{}, false
|
|
}
|
|
return entry.inode, entry.attr, true
|
|
}
|
|
|
|
// snapshot returns the purge generation. A caller about to resolve outside
|
|
// the lock passes it back to insert, which discards the entry if any purge ran
|
|
// in between.
|
|
func (c *pathCache) snapshot() uint64 {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.gen
|
|
}
|
|
|
|
// insert takes ownership of one lookup reference on inode, in every outcome:
|
|
// an entry a covering purge outdated goes to the graveyard instead of the map,
|
|
// so the caller may keep using the inode for at least one sweep either way.
|
|
func (c *pathCache) insert(key string, inode uint64, attr fuse.Attr, gen uint64) {
|
|
if key == "" {
|
|
c.forget(inode)
|
|
return
|
|
}
|
|
var pending []uint64
|
|
c.mu.Lock()
|
|
if c.purgedSince(gen, key) {
|
|
c.graveyard = append(c.graveyard, inode)
|
|
pending = c.sweepLocked()
|
|
c.mu.Unlock()
|
|
c.forgetAll(pending)
|
|
return
|
|
}
|
|
if existing, found := c.entries[key]; found {
|
|
c.graveyard = append(c.graveyard, existing.inode)
|
|
} else if len(c.entries) >= maxCachedPaths {
|
|
for _, entry := range c.entries {
|
|
c.graveyard = append(c.graveyard, entry.inode)
|
|
}
|
|
c.entries = map[string]*pathCacheEntry{}
|
|
}
|
|
c.entries[key] = &pathCacheEntry{inode: inode, attr: attr, expires: time.Now().Add(c.ttl)}
|
|
pending = c.sweepLocked()
|
|
c.mu.Unlock()
|
|
c.forgetAll(pending)
|
|
}
|
|
|
|
// steal removes key and hands its reference to the caller.
|
|
func (c *pathCache) steal(key string) (inode uint64, ok bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
entry, found := c.entries[key]
|
|
if !found {
|
|
return 0, false
|
|
}
|
|
delete(c.entries, key)
|
|
return entry.inode, true
|
|
}
|
|
|
|
// purge drops key, and everything under it when prefix is set, which a rename
|
|
// or removal of a directory needs: the children's cached paths name entries
|
|
// that are no longer there.
|
|
func (c *pathCache) purge(key string, prefix bool) {
|
|
var pending []uint64
|
|
c.mu.Lock()
|
|
// Recorded even when the map holds nothing: the point is as much the
|
|
// in-flight resolve about to insert this very name.
|
|
c.gen++
|
|
c.recentPurges = append(c.recentPurges, purgeRecord{key: key, prefix: prefix})
|
|
if len(c.recentPurges) > maxRecentPurges {
|
|
c.recentPurges = append(c.recentPurges[:0], c.recentPurges[len(c.recentPurges)-maxRecentPurges:]...)
|
|
}
|
|
if entry, found := c.entries[key]; found {
|
|
c.graveyard = append(c.graveyard, entry.inode)
|
|
delete(c.entries, key)
|
|
}
|
|
if prefix {
|
|
under := key + "/"
|
|
for k, entry := range c.entries {
|
|
if key == "" || strings.HasPrefix(k, under) {
|
|
c.graveyard = append(c.graveyard, entry.inode)
|
|
delete(c.entries, k)
|
|
}
|
|
}
|
|
}
|
|
pending = c.sweepLocked()
|
|
c.mu.Unlock()
|
|
c.forgetAll(pending)
|
|
}
|
|
|
|
// sweepLocked returns the generation before last for the caller to forget
|
|
// outside the lock, and retires the current one. Sweeps run at most once per
|
|
// ttl and an appended reference sits out the sweep of its own call, so every
|
|
// reference rests for at least one full ttl after its last possible use.
|
|
func (c *pathCache) sweepLocked() []uint64 {
|
|
now := time.Now()
|
|
if now.Sub(c.lastSweep) < c.ttl {
|
|
return nil
|
|
}
|
|
c.lastSweep = now
|
|
pending := c.prevGraveyard
|
|
c.prevGraveyard = c.graveyard
|
|
c.graveyard = nil
|
|
for key, entry := range c.entries {
|
|
if now.After(entry.expires) {
|
|
// Returned by the next sweep, a full ttl away: served from the map
|
|
// until a moment ago, so someone may still be holding it.
|
|
c.prevGraveyard = append(c.prevGraveyard, entry.inode)
|
|
delete(c.entries, key)
|
|
}
|
|
}
|
|
return pending
|
|
}
|
|
|
|
func (c *pathCache) forgetAll(inodes []uint64) {
|
|
for _, inode := range inodes {
|
|
c.forget(inode)
|
|
}
|
|
}
|