Files
seaweedfs/weed/mount/posix_file_lock.go
T
Chris LuandDevin 0ea818a44f mount: drain OFD locks when a file description is released (#12117)
* mount: drain OFD locks when a file description is released

The kernel scopes OFD fcntl locks to the open file description but
reports no lock_owner for them at RELEASE — it only sends one for flock
holders — so an OFD write lock taken on a file handle outlived the
holder and blocked every later open of that file (e.g. etcd failing to
reopen its WAL after a restart). The mount now records the owner of each
granted non-flock lock under its file handle and releases those owners
on RELEASE, which also covers any POSIX locks left over when FLUSH could
not run.

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

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

* mount: give every open its own fh value over the shared handle

AcquireFileHandle reuses one FileHandle per inode, so every open reported
the same fh to the kernel. A RELEASE for one description could then not
be told apart from a sibling still holding the inode open — draining
lock owners recorded under the shared fh dropped locks belonging to
descriptions that stay open.

Each open now registers its own fh value in fh2inode, all resolving to
the shared FileHandle, and RELEASE removes exactly the id that is
closing. This also stops a duplicated RELEASE on one id from
double-decrementing the open count.

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

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

* mount: forget flushed lock owners so a later release cannot re-drop locks

POSIX lock owners are process-scoped: Flush releases every lock the
process holds on the inode, but the owner stayed recorded under each
open's fh for the Release-time drain. If the process took a fresh lock
through another open before an old description finished closing, the
stale record made that RELEASE drop the new lock too.

Flush now drops the released owner from every recorded open of the
inode, so the Release drain only ever fires for owners whose locks were
never flushed.

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

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

* mount: keep close-time lock owner records when the release fails

A routed POSIX owner release that errors keeps the flush hint for a
retry, but Flush also dropped the owner from every open's release
records unconditionally — so the final Release skipped the retry and
the owner filer's lock was stranded until lease reaping. Only forget
the owner once its locks were actually released.

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 22:02:13 -07:00

494 lines
14 KiB
Go

package mount
import (
"math"
"sort"
"sync"
"github.com/seaweedfs/go-fuse/v2/fuse"
)
// lockRange represents a single held POSIX byte-range lock.
type lockRange struct {
Start uint64 // inclusive byte offset
End uint64 // inclusive; math.MaxUint64 means "to EOF"
Typ uint32 // f_RDLCK or f_WRLCK
Owner uint64 // FUSE lock owner (from LkIn.Owner)
Pid uint32 // PID of lock holder (for GetLk reporting)
// flock and fcntl locks have different ownership and close semantics.
// Keep them in separate namespaces inside the table.
IsFlock bool
}
// inodeLocks holds all locks for one inode plus a waiter queue for SetLkw.
type inodeLocks struct {
mu sync.Mutex
locks []lockRange // currently held locks, sorted by Start
waiters []*lockWaiter // blocked SetLkw callers
wakeRefs int // woken waiters still retrying on this inodeLocks
// dead marks an inodeLocks that has been removed from the table's map.
// A caller holding a pointer returned by getInodeLocks/getOrCreateInodeLocks
// must recheck this flag after acquiring il.mu: if true, the pointer is
// orphaned and must be refreshed from the table so findConflict runs
// against the live state.
dead bool
}
// lockWaiter represents a blocked SetLkw caller.
type lockWaiter struct {
requested lockRange // the lock this waiter is trying to acquire
ch chan struct{} // closed when the waiter should re-check
wakeRefHeld bool
}
// fhLockOwners maps each open's fh value to the non-flock lock owners that
// acquired locks through it. The kernel scopes POSIX fcntl locks to the
// process — released on FLUSH by LockOwner — while OFD fcntl locks are scoped
// to the open file description and get no cleanup notice: RELEASE carries a
// lock_owner only for flock holders. Tracking owners per open lets Release
// drop the locks of the description that is closing while sibling opens of
// the same inode keep theirs.
type fhLockOwners struct {
mu sync.Mutex
m map[uint64]map[uint64]struct{}
}
func (l *fhLockOwners) add(fh, owner uint64) {
l.mu.Lock()
defer l.mu.Unlock()
if l.m == nil {
l.m = make(map[uint64]map[uint64]struct{})
}
owners := l.m[fh]
if owners == nil {
owners = make(map[uint64]struct{})
l.m[fh] = owners
}
owners[owner] = struct{}{}
}
func (l *fhLockOwners) pop(fh uint64) map[uint64]struct{} {
l.mu.Lock()
defer l.mu.Unlock()
owners := l.m[fh]
delete(l.m, fh)
return owners
}
func (l *fhLockOwners) remove(fh, owner uint64) {
l.mu.Lock()
defer l.mu.Unlock()
owners := l.m[fh]
if owners == nil {
return
}
delete(owners, owner)
if len(owners) == 0 {
delete(l.m, fh)
}
}
// PosixLockTable is the per-mount POSIX lock manager.
type PosixLockTable struct {
mu sync.Mutex
inodes map[uint64]*inodeLocks
}
func NewPosixLockTable() *PosixLockTable {
return &PosixLockTable{
inodes: make(map[uint64]*inodeLocks),
}
}
// getOrCreateInodeLocks returns the lock state for an inode, creating it if
// needed. A dead entry (which maybeCleanupInode normally evicts together
// with setting dead) is replaced transparently so callers never observe one
// via this path and the SetLk retry loop cannot spin on a stale map entry.
// The il.dead read is safe without il.mu: dead transitions only under both
// plt.mu and il.mu, so holding plt.mu here is sufficient happens-before.
func (plt *PosixLockTable) getOrCreateInodeLocks(inode uint64) *inodeLocks {
plt.mu.Lock()
defer plt.mu.Unlock()
il, ok := plt.inodes[inode]
if !ok || il.dead {
il = &inodeLocks{}
plt.inodes[inode] = il
}
return il
}
// getInodeLocks returns the lock state for an inode, or nil if none exists.
// A dead entry (which maybeCleanupInode normally evicts together with setting
// dead) is dropped so callers never observe an orphaned inodeLocks via this
// path. The il.dead read is safe without il.mu for the same reason as in
// getOrCreateInodeLocks: transitions happen under both plt.mu and il.mu.
func (plt *PosixLockTable) getInodeLocks(inode uint64) *inodeLocks {
plt.mu.Lock()
defer plt.mu.Unlock()
il, ok := plt.inodes[inode]
if !ok {
return nil
}
if il.dead {
delete(plt.inodes, inode)
return nil
}
return il
}
// maybeCleanupInode removes the inodeLocks entry if it has no locks, no waiters,
// and no woken waiters still retrying against this inodeLocks. The removed
// inodeLocks is marked dead so any caller that is mid-way through acquiring
// il.mu with a stale pointer will notice and refetch from the map instead of
// mutating an orphaned instance (which would be invisible to future callers).
func (plt *PosixLockTable) maybeCleanupInode(inode uint64, il *inodeLocks) {
// Caller must NOT hold il.mu. We acquire both locks in the correct order.
plt.mu.Lock()
defer plt.mu.Unlock()
il.mu.Lock()
defer il.mu.Unlock()
if len(il.locks) == 0 && len(il.waiters) == 0 && il.wakeRefs == 0 {
if plt.inodes[inode] == il {
delete(plt.inodes, inode)
}
il.dead = true
}
}
// rangesOverlap returns true if two inclusive ranges overlap.
func rangesOverlap(aStart, aEnd, bStart, bEnd uint64) bool {
return aStart <= bEnd && bStart <= aEnd
}
// findConflict returns the first lock that conflicts with the proposed lock.
// A conflict exists when ranges overlap, at least one is a write lock, and the owners differ.
func findConflict(locks []lockRange, proposed lockRange) (lockRange, bool) {
for _, h := range locks {
if h.IsFlock != proposed.IsFlock {
continue
}
if h.Owner == proposed.Owner {
continue
}
if !rangesOverlap(h.Start, h.End, proposed.Start, proposed.End) {
continue
}
if h.Typ == f_RDLCK && proposed.Typ == f_RDLCK {
continue
}
return h, true
}
return lockRange{}, false
}
// insertAndCoalesce inserts a lock for the given owner, replacing/splitting any
// existing same-owner locks that overlap. Adjacent same-type locks are merged.
// Caller must hold il.mu.
func insertAndCoalesce(il *inodeLocks, lk lockRange) {
owner := lk.Owner
var kept []lockRange
for _, h := range il.locks {
if h.Owner != owner || h.IsFlock != lk.IsFlock {
kept = append(kept, h)
continue
}
if !rangesOverlap(h.Start, h.End, lk.Start, lk.End) {
// Check for adjacency with same type for merging.
if h.Typ == lk.Typ && ((h.End < ^uint64(0) && h.End+1 == lk.Start) || (lk.End < ^uint64(0) && lk.End+1 == h.Start)) {
// Merge adjacent same-type lock into lk.
if h.Start < lk.Start {
lk.Start = h.Start
}
if h.End > lk.End {
lk.End = h.End
}
continue
}
kept = append(kept, h)
continue
}
// Overlapping same-owner lock.
if h.Typ == lk.Typ {
// Same type: absorb into lk (expand range).
if h.Start < lk.Start {
lk.Start = h.Start
}
if h.End > lk.End {
lk.End = h.End
}
continue
}
// Different type: truncate or split the existing lock.
if h.Start < lk.Start {
// Left portion survives.
left := h
left.End = lk.Start - 1
kept = append(kept, left)
}
if h.End > lk.End {
// Right portion survives.
right := h
right.Start = lk.End + 1
kept = append(kept, right)
}
}
kept = append(kept, lk)
sort.Slice(kept, func(i, j int) bool {
return kept[i].Start < kept[j].Start
})
il.locks = kept
}
// removeLocks removes or splits matching locks in the given range.
// Caller must hold il.mu.
func removeLocks(il *inodeLocks, matches func(lockRange) bool, start, end uint64) {
var kept []lockRange
for _, h := range il.locks {
if !matches(h) || !rangesOverlap(h.Start, h.End, start, end) {
kept = append(kept, h)
continue
}
// h overlaps the unlock range.
if h.Start < start {
// Left portion survives.
left := h
left.End = start - 1
kept = append(kept, left)
}
if h.End > end {
// Right portion survives.
right := h
right.Start = end + 1
kept = append(kept, right)
}
// If fully contained, it's simply dropped.
}
il.locks = kept
}
func (plt *PosixLockTable) releaseMatching(inode uint64, matches func(lockRange) bool) {
il := plt.getInodeLocks(inode)
if il == nil {
return
}
il.mu.Lock()
removeLocks(il, matches, 0, math.MaxUint64)
wakeEligibleWaiters(il)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
}
// HasPosixOwner reports whether owner currently holds any POSIX byte-range
// locks on inode. FUSE may provide a non-zero FlushIn.LockOwner even when no
// locks were taken, so callers should consult the lock table before treating a
// flush as lock-sensitive.
func (plt *PosixLockTable) HasPosixOwner(inode uint64, owner uint64) bool {
if owner == 0 {
return false
}
il := plt.getInodeLocks(inode)
if il == nil {
return false
}
il.mu.Lock()
defer il.mu.Unlock()
if il.dead {
return false
}
for _, lk := range il.locks {
if !lk.IsFlock && lk.Owner == owner {
return true
}
}
return false
}
// releaseWakeRef drops the temporary reference that keeps inodeLocks live while
// a woken waiter retries its SetLkw acquisition.
func releaseWakeRef(il *inodeLocks, waiter *lockWaiter) {
if waiter == nil || !waiter.wakeRefHeld {
return
}
waiter.wakeRefHeld = false
il.wakeRefs--
}
// wakeEligibleWaiters selectively wakes blocked SetLkw callers that can now
// succeed given the current lock state. Waiters whose requests still conflict
// with held locks remain in the queue, avoiding a thundering herd.
// Caller must hold il.mu.
func wakeEligibleWaiters(il *inodeLocks) {
remaining := il.waiters[:0]
for _, w := range il.waiters {
if _, conflicted := findConflict(il.locks, w.requested); !conflicted {
w.wakeRefHeld = true
il.wakeRefs++
close(w.ch)
} else {
remaining = append(remaining, w)
}
}
il.waiters = remaining
}
// removeWaiter removes a specific waiter from the list.
// Caller must hold il.mu.
func removeWaiter(il *inodeLocks, w *lockWaiter) {
for i, existing := range il.waiters {
if existing == w {
il.waiters = append(il.waiters[:i], il.waiters[i+1:]...)
return
}
}
}
// GetLk checks for a conflicting lock. If found, it populates out with the
// conflict details. If no conflict, out.Typ is set to F_UNLCK.
func (plt *PosixLockTable) GetLk(inode uint64, proposed lockRange, out *fuse.LkOut) {
for {
il := plt.getInodeLocks(inode)
if il == nil {
out.Lk.Typ = f_UNLCK
return
}
il.mu.Lock()
if il.dead {
// Orphaned by a concurrent cleanup: the map may already hold a
// fresh inodeLocks with different state, so answering from this
// stale pointer would miss real conflicts. Refetch.
il.mu.Unlock()
continue
}
conflict, found := findConflict(il.locks, proposed)
il.mu.Unlock()
if found {
out.Lk.Start = conflict.Start
out.Lk.End = conflict.End
out.Lk.Typ = conflict.Typ
out.Lk.Pid = conflict.Pid
} else {
out.Lk.Typ = f_UNLCK
}
return
}
}
// SetLk attempts a non-blocking lock or unlock.
// For unlock (F_UNLCK): removes locks in the given range for the owner.
// For lock: returns fuse.EAGAIN if a conflict exists, fuse.OK on success.
func (plt *PosixLockTable) SetLk(inode uint64, lk lockRange) fuse.Status {
if lk.Typ == f_UNLCK {
il := plt.getInodeLocks(inode)
if il == nil {
return fuse.OK
}
il.mu.Lock()
if il.dead {
// Orphaned by a concurrent cleanup: any lock we may have held
// is gone with it, and there is no point waking waiters on a
// detached inodeLocks.
il.mu.Unlock()
return fuse.OK
}
removeLocks(il, func(existing lockRange) bool {
return existing.Owner == lk.Owner && existing.IsFlock == lk.IsFlock
}, lk.Start, lk.End)
wakeEligibleWaiters(il)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
return fuse.OK
}
for {
il := plt.getOrCreateInodeLocks(inode)
il.mu.Lock()
if il.dead {
il.mu.Unlock()
continue
}
if _, found := findConflict(il.locks, lk); found {
il.mu.Unlock()
return fuse.EAGAIN
}
insertAndCoalesce(il, lk)
il.mu.Unlock()
return fuse.OK
}
}
// SetLkw attempts a blocking lock. It waits until the lock can be acquired
// or the cancel channel is closed.
func (plt *PosixLockTable) SetLkw(inode uint64, lk lockRange, cancel <-chan struct{}) fuse.Status {
if lk.Typ == f_UNLCK {
return plt.SetLk(inode, lk)
}
il := plt.getOrCreateInodeLocks(inode)
var waiter *lockWaiter
for {
il.mu.Lock()
if il.dead {
// The inodeLocks we were holding was orphaned by a concurrent
// cleanup. Refetch the live instance from the table and retry.
// A dead il never has waiters or wakeRefs (the cleanup condition
// requires both to be zero), so waiter is guaranteed nil here.
il.mu.Unlock()
il = plt.getOrCreateInodeLocks(inode)
continue
}
releaseWakeRef(il, waiter)
if _, found := findConflict(il.locks, lk); !found {
insertAndCoalesce(il, lk)
il.mu.Unlock()
return fuse.OK
}
// Register waiter with the requested lock details for selective waking.
waiter = &lockWaiter{requested: lk, ch: make(chan struct{})}
il.waiters = append(il.waiters, waiter)
il.mu.Unlock()
// Block until woken or cancelled.
select {
case <-waiter.ch:
// Woken — retry.
continue
case <-cancel:
// Request cancelled.
il.mu.Lock()
releaseWakeRef(il, waiter)
removeWaiter(il, waiter)
il.mu.Unlock()
plt.maybeCleanupInode(inode, il)
return fuse.EINTR
}
}
}
// ReleaseOwner removes all locks held by the given owner on the given inode.
// Used for same-owner cleanup in tests and lock-table operations.
func (plt *PosixLockTable) ReleaseOwner(inode uint64, owner uint64) {
plt.releaseMatching(inode, func(lk lockRange) bool {
return lk.Owner == owner
})
}
// ReleaseFlockOwner removes flock locks for a released file description.
// FUSE only provides LockOwner on RELEASE when FUSE_RELEASE_FLOCK_UNLOCK is set.
func (plt *PosixLockTable) ReleaseFlockOwner(inode uint64, owner uint64) {
plt.releaseMatching(inode, func(lk lockRange) bool {
return lk.IsFlock && lk.Owner == owner
})
}
// ReleasePosixOwner removes POSIX fcntl locks for a closing lock owner.
// FUSE passes the closing fi->owner on FLUSH, which is the correct close-time
// identity for POSIX byte-range lock cleanup.
func (plt *PosixLockTable) ReleasePosixOwner(inode uint64, owner uint64) {
plt.releaseMatching(inode, func(lk lockRange) bool {
return !lk.IsFlock && lk.Owner == owner
})
}