Files
seaweedfs/weed/cluster/lock_manager/lock_ring.go
T
Chris LuGitHubDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com>Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
f31a026b2a master,filer: fix lock ring poisoning after leader change (#11453)
* cluster: never broadcast an empty lock ring

An empty member list is never a usable ring state, but a delayed
RemoveServer on a former leader can fire after the new leader already
broadcast the recovered ring. That late broadcast carries a newer
wall-clock version, so clients accept the empty ring and permanently
reject the good one.

Skip the broadcast entirely when the member list is empty, keeping the
last non-empty snapshot for reconnecting clients.

* cluster: periodically rebroadcast the lock ring

Ring updates are purely event-driven, so one lost or poisoned update is
permanent until the next membership change — with a single filer that may
never come. Re-arm a per-group timer after every broadcast so the current
leader keeps re-sending the ring; clients reject nothing newer than their
last accepted version, so a re-sent snapshot always heals a stale view.

* filer,s3api: reset the lock ring on master change

Ring versions are per-master monotonic — each master stamps wall-clock
nanoseconds — so a late high-version update accepted from a former leader
makes the new leader's snapshot look stale forever. Detect a leader
change across the reconnect gap (currentMaster is cleared between
attempts, so remember the last served master) and reset the ring to
bootstrap state so the new leader's view always applies.

* cluster: fail lock acquisition when no lock server exists

retryUntilLocked loops forever, so a filer reporting an empty lock ring
wedges every append write indefinitely. Bound only the "no lock server
found" case — ordinary contention is still waited out since the holder
releases eventually. The constructors now return nil on failure: the
filer append path and S3 object writes fail fast, while mounts degrade
to their existing lockless mode.

* cluster: reset only the ring version on master change

Ring versions are per-master monotonic, so a version gate reset is all a
leader change needs. Clearing the whole ring made every filer its own
write owner until the next update and dropped the prior-owner window for
keys the new leader remaps; the last ring now keeps routing until the
new leader's snapshot transitions off it.

* cluster: skip redundant ring installs and defer rebroadcasts

An unchanged member list now only bumps the accepted version instead of
installing a snapshot: periodic rebroadcasts no longer fire the
topology-change callback or restart the prior-owner window. And a
rebroadcast that lands inside a membership stabilization window yields
to the pending timer rather than publishing an intermediate ring.

* cluster,mount: bound lock unavailability, fail ops that cannot lock

Only 'lock already owned' contention retries without bound now; every
other failure — no lock server, or a dead ring member refusing
connections — shares the same unavailability budget, so a ring naming
departed filers can no longer hang a lock forever. Mount open-write,
create, and rename fail with EAGAIN when the required lock cannot be
acquired instead of proceeding without cross-mount serialization.

* cluster: check pending stabilization inside the broadcast critical section

rebroadcast released the mutex between the pending-timer check and
nextBroadcastUpdate, so a membership change arriving in the gap could arm
a stabilization timer while the rebroadcast emitted an intermediate ring.

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

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

* mount: acquire path locks before mutating create/rename state

Create took the DLM lock only after the filer create, so a lock failure
returned EAGAIN with an eagerly persisted file left behind. Rename marked
source handles renamed before acquiring locks, so a failed acquisition
left them suppressing old-path flushes for a rename that never happened.
Both now take the locks first; the create's lock is released again if the
entry race loses to another creator and AcquireHandle takes over.

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

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

* mount: keep the old-path lock when rename lock migration fails

The migration stopped the handle's lock before acquiring the replacement,
so a nil result left the handle writing with no lock at all. Acquiring the
new-path lock first means failure keeps the existing lock instead of
reporting success with serialization dropped.

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

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

* mount: skip new-path rename lock when a handle already holds it

A target file open for write on this mount already carries a lock on
newPath; the lock manager does not grant a second lock to the same
owner, so the rename would wait on itself until the handle closed.
Also avoid locking twice when old and new paths coincide.

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

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

* mount: hand the rename's target lock to the migrating handle

The rename holds a lock on newPath for its duration, so the response
migration's fresh acquisition waited on that same lock until the handle
released — under fhLockTable, blocking the handle's own close. Adopt the
rename's lock directly; nested move responses still acquire their own.

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

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

* mount: move the replaced target's lock to the renamed handle

When the target path was already locked by an open handle on this
mount, the migrated source handle kept only its stale old-path lock —
the target's close would then release the last lock on the new path
while the renamed handle was still open. Adopt the replaced handle's
lock instead.

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

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

* mount: stop the handle lock inside the fh lock on release

ReleaseHandle stopped fh.dlmLock before taking the fhLockTable slot, so
a rename migration holding that slot could still observe and adopt a
lock that was already stopping. Stopping under the fh lock makes the
transfer serialize against the release.

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

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

* mount: claim the replaced target's lock for the renamed handle

When the target path is already locked by an open handle on this mount,
adopting it at migration time keeps the renamed path protected after
that handle closes, without waiting on a lock this mount already holds.
If the handle was released mid-migration the claimed lock is stopped,
and a fresh acquire covers the case where it was already gone.

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

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

* mount: claim the target handle's lock before the rename runs

Skipping the new-path lock when a handle already holds it let that
handle's close release the lock mid-rename, leaving the path unguarded
until the response migrated it. Take over the lock at check time and
hold it for the rename's duration: the response adopts it for the
migrating handle, or it returns to the target handle / is released on
failure. The target handle lookup also falls back to the entry's stored
inode for a forgotten path mapping.

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

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

* mount: read handle locks only under the fh lock during rename

The loose dlmLock reads raced ReleaseHandle, which now mutates the lock
inside the handle lock; check and claim it under the same hold.

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

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

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-26 11:57:00 +08:00

234 lines
7.0 KiB
Go

package lock_manager
import (
"slices"
"sort"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
type LockRingSnapshot struct {
servers []pb.ServerAddress
ts time.Time
ring *HashRing // prebuilt ring for servers, so PriorOwner need not rebuild per call
}
type LockRing struct {
sync.RWMutex
snapshots []*LockRingSnapshot
lastCompactTime time.Time
snapshotInterval time.Duration
onTakeSnapshot func(snapshot []pb.ServerAddress)
cleanupWg sync.WaitGroup
Ring *HashRing // consistent hash ring
version int64 // monotonic version from master, rejects stale updates
}
func NewLockRing(snapshotInterval time.Duration) *LockRing {
return &LockRing{
snapshotInterval: snapshotInterval,
Ring: NewHashRing(DefaultVnodeCount),
}
}
func (r *LockRing) SetTakeSnapshotCallback(onTakeSnapshot func(snapshot []pb.ServerAddress)) {
r.Lock()
defer r.Unlock()
r.onTakeSnapshot = onTakeSnapshot
}
// SetSnapshot replaces the ring with a new server list from the master.
// The version must be >= the current version, otherwise the update is rejected
// (protects against reordered messages). Version 0 is always accepted (bootstrap).
func (r *LockRing) SetSnapshot(servers []pb.ServerAddress, version int64) bool {
sort.Slice(servers, func(i, j int) bool {
return servers[i] < servers[j]
})
r.Lock()
if version > 0 && version < r.version {
glog.V(0).Infof("LockRing: rejecting stale update v%d (current v%d)", version, r.version)
r.Unlock()
return false
}
// An unchanged member list is only a version refresh: installing it as a
// new snapshot would run the topology-change callback and restart the
// prior-owner window on every periodic rebroadcast.
if len(r.snapshots) > 0 && slices.Equal(servers, r.snapshots[0].servers) {
r.version = version
r.Unlock()
return true
}
r.version = version
// Update the ring while holding the lock so version and ring state
// are always consistent — prevents a concurrent SetSnapshot from
// seeing the new version but applying its servers to the old ring.
r.Ring.SetServers(servers)
// Append the snapshot under the same lock as the ring update so a concurrent
// PriorOwner always sees snapshots[0] matching r.Ring (and snapshots[1] as the
// true prior); otherwise it could pair a new ring with a stale prior snapshot.
r.addOneSnapshotLocked(servers)
r.Unlock()
r.cleanupWg.Add(1)
go func() {
defer r.cleanupWg.Done()
<-time.After(r.snapshotInterval)
r.compactSnapshots()
}()
return true
}
// Reset clears only the version gate so the first update from a different
// master always applies: ring versions are per-master monotonic, and a high
// version accepted from a former leader must not reject the new leader's
// view. The ring itself stays installed — writes keep routing to the last
// known owner during the gap instead of every filer treating itself as the
// owner, and the arriving snapshot transitions off it with the usual
// prior-owner window.
func (r *LockRing) Reset() {
r.Lock()
defer r.Unlock()
r.version = 0
}
// Version returns the current ring version.
func (r *LockRing) Version() int64 {
r.RLock()
defer r.RUnlock()
return r.version
}
// addOneSnapshotLocked appends a new snapshot (newest at index 0). The caller
// must hold r.Lock(), so the ring update and snapshot append are one atomic step.
func (r *LockRing) addOneSnapshotLocked(servers []pb.ServerAddress) {
ts := time.Now()
ring := NewHashRing(DefaultVnodeCount)
ring.SetServers(servers)
t := &LockRingSnapshot{
servers: servers,
ts: ts,
ring: ring,
}
r.snapshots = append(r.snapshots, t)
for i := len(r.snapshots) - 2; i >= 0; i-- {
r.snapshots[i+1] = r.snapshots[i]
}
r.snapshots[0] = t
if r.onTakeSnapshot != nil {
r.onTakeSnapshot(t.servers)
}
}
func (r *LockRing) compactSnapshots() {
r.Lock()
defer r.Unlock()
ts := time.Now()
recentSnapshotIndex := 1
for ; recentSnapshotIndex < len(r.snapshots); recentSnapshotIndex++ {
if ts.Sub(r.snapshots[recentSnapshotIndex].ts) > r.snapshotInterval {
break
}
}
if recentSnapshotIndex+1 <= len(r.snapshots) {
r.snapshots = r.snapshots[:recentSnapshotIndex+1]
}
r.lastCompactTime = ts
}
func (r *LockRing) GetSnapshot() (servers []pb.ServerAddress) {
r.RLock()
defer r.RUnlock()
if len(r.snapshots) == 0 {
return
}
return r.snapshots[0].servers
}
// WaitForCleanup waits for all pending cleanup operations to complete
func (r *LockRing) WaitForCleanup() {
r.cleanupWg.Wait()
}
// GetSnapshotCount safely returns the number of snapshots for testing
func (r *LockRing) GetSnapshotCount() int {
r.RLock()
defer r.RUnlock()
return len(r.snapshots)
}
// GetPrimaryAndBackup returns the primary and backup servers for a key
// using the consistent hash ring.
func (r *LockRing) GetPrimaryAndBackup(key string) (primary, backup pb.ServerAddress) {
return r.Ring.GetPrimaryAndBackup(key)
}
// GetPrimary returns the primary server for a key using the consistent hash ring.
func (r *LockRing) GetPrimary(key string) pb.ServerAddress {
return r.Ring.GetPrimary(key)
}
// PriorOwner returns the key's owner from the previous ring snapshot, but only
// while the ring changed within the last snapshotInterval and that owner differs
// from the current primary. This is the cooling-off window in which the previous
// owner may still hold locks the new owner has not yet rebuilt — a caller can
// consult it before granting so a fresh owner does not double-grant during a
// rebalance. Returns "" outside the window or when ownership did not move. It
// uses the snapshot's prebuilt ring, so it does not rebuild a hash ring per call.
func (r *LockRing) PriorOwner(key string) pb.ServerAddress {
r.RLock()
defer r.RUnlock()
return r.priorOwnerLocked(key)
}
// WriteOwner returns the filer that should serialize writes to key: the prior
// owner while a ring change is still within the cooling-off window, otherwise
// the current primary. Both are read under one lock so the pair cannot come
// from different rings, which could otherwise name the same filer twice.
func (r *LockRing) WriteOwner(key string) pb.ServerAddress {
r.RLock()
defer r.RUnlock()
if prior := r.priorOwnerLocked(key); prior != "" {
return prior
}
return r.Ring.GetPrimary(key)
}
// priorOwnerLocked is PriorOwner's body; the caller holds at least RLock.
func (r *LockRing) priorOwnerLocked(key string) pb.ServerAddress {
if len(r.snapshots) < 2 {
return ""
}
if time.Since(r.snapshots[0].ts) > r.snapshotInterval {
return ""
}
current := r.Ring.GetPrimary(key)
var prior pb.ServerAddress
if pr := r.snapshots[1].ring; pr != nil {
prior = pr.GetPrimary(key)
} else {
prior = hashKeyToServer(key, r.snapshots[1].servers)
}
if prior != "" && prior != current {
return prior
}
return ""
}
// hashKeyToServer uses a temporary consistent hash ring for the given server list.
func hashKeyToServer(key string, servers []pb.ServerAddress) pb.ServerAddress {
if len(servers) == 0 {
return ""
}
ring := NewHashRing(DefaultVnodeCount)
ring.SetServers(servers)
return ring.GetPrimary(key)
}