Files
seaweedfs/weed/server/master_grpc_server_test.go
d7ae08ecad fix(mount): refuse stale whole-entry content flushes (#12594)
* fix(mount): condition content flushes on the observed entry

* mount: stop retrying a flush the filer refused on its condition

The filer reports a failed CreateEntry condition as FilerError_PRECONDITION_FAILED
in the response, which surfaces as filer_pb.ErrPreconditionFailed rather than a
FailedPrecondition status. The flush retry loop treated it as transient and
replayed the refused flush three more times over 7 s before returning EIO.

The test filer now answers like the real one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: take the handle base from the entry the filer stored

After an acknowledged flush the handle base was the requested entry, but the
filer stores it with a fresh commit_ts_ns (and fills crtime, inode, atime), so
the next IF_ENTRY_EQUAL flush on the same handle never matched: write, fsync,
write, fsync returned EIO, as did close after a periodic metadata flush and the
first flush after an eager create. Use the stored entry from the
acknowledgement's metadata event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: condition the periodic metadata flush like the close flush

The periodic metadata flush of an open, dirty handle sent the same whole entry
through an unconditional CreateEntry, so a file held open past the flush
interval still overwrote a peer's acknowledged append, and the close flush then
matched the overwritten state. Both paths now publish through one helper that
carries the handle's write condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: republish a refused flush once when the stored entry is unchanged

A handle's base can differ from the stored entry without any peer write: a
rename while the file is open restamps the entry under its new name, and with
-map.uid a filer id that is also a mapped local id does not map back to itself.
Each made every flush of the handle fail with EIO. On a miss, read the stored
entry and, if it still holds the observed state apart from name, commit stamp,
id encoding and derived size, retry once with that entry as the condition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: take the filer's entry once a write open holds the DLM lock

The lock orders this writer after the previous holder, but the holder's
flush may not have reached the local cache yet, so the handle's base
named the entry from before the wait and the conditional flush refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: share the open-existing path of Create

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: open the file a previous DLM holder created during a deferred create

A deferred create never asks the filer, so a create that waited for the
lock went on to publish over a file the previous holder had just made,
and its flush expecting no entry was refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* master: send the lock ring snapshot to mounts

A mount running -dlm is a lock client like the S3 gateway, and needs the
ring on connect to reach a file's owner filer before the next membership
change is broadcast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: follow the masters' lock ring under -dlm

The lock client only knew the seed filer, so every lock took the forward
hop and the mount had no way to tell which filer owns a file. Subscribe to
the masters named by the filer, like the S3 gateway, and wait briefly for
the first ring before serving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* mount: read the DLM-ordered entry from the file's owner filer

The filer checks a flush's condition on the owner of the path's write
route key, and another filer may not have applied the previous holder's
flush yet. Look the entry up on that owner once the ring is known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 16:15:08 -08:00

244 lines
9.7 KiB
Go

package weed_server
import (
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInitialLockRingUpdateReturnsLastBroadcastForFilers(t *testing.T) {
ms := &MasterServer{
LockRingManager: cluster.NewLockRingManager(nil),
}
ms.LockRingManager.AddServer("group-a", "filer1:8888")
ms.LockRingManager.AddServer("group-a", "filer2:8888")
ms.LockRingManager.FlushPending("group-a")
resp := ms.initialLockRingUpdate(cluster.FilerType, "group-a")
require.NotNil(t, resp)
require.NotNil(t, resp.LockRingUpdate)
assert.Equal(t, "group-a", resp.LockRingUpdate.FilerGroup)
assert.ElementsMatch(t, []string{"filer1:8888", "filer2:8888"}, resp.LockRingUpdate.Servers)
assert.Greater(t, resp.LockRingUpdate.Version, int64(0))
}
func TestLeaveLockRingDropsOnlyTheLeavingFiler(t *testing.T) {
ms := &MasterServer{
LockRingManager: cluster.NewLockRingManager(nil),
}
ms.LockRingManager.AddServer("group-a", "filer1:8888")
ms.LockRingManager.AddServer("group-a", "filer2:8888")
ms.LockRingManager.FlushPending("group-a")
registered := &master_pb.KeepConnectedRequest{ClientType: cluster.FilerType, FilerGroup: "group-a"}
ms.onKeepConnectedMessage(registered, "filer1:8888", &master_pb.KeepConnectedRequest{})
ms.LockRingManager.FlushPending("group-a")
assert.ElementsMatch(t, []string{"filer1:8888", "filer2:8888"}, ms.LockRingManager.GetServers("group-a"))
ms.onKeepConnectedMessage(registered, "filer1:8888", &master_pb.KeepConnectedRequest{LeaveLockRing: true})
ms.LockRingManager.FlushPending("group-a")
assert.ElementsMatch(t, []string{"filer2:8888"}, ms.LockRingManager.GetServers("group-a"))
s3 := &master_pb.KeepConnectedRequest{ClientType: cluster.S3Type, FilerGroup: "group-a"}
ms.onKeepConnectedMessage(s3, "filer2:8888", &master_pb.KeepConnectedRequest{LeaveLockRing: true})
ms.LockRingManager.FlushPending("group-a")
assert.ElementsMatch(t, []string{"filer2:8888"}, ms.LockRingManager.GetServers("group-a"))
}
// A filer's replacement can register under the same address before the master
// ends the old stream; that stream's cleanup must leave the live filer in the ring.
func TestOverlappedFilerRegistrationStaysInLockRing(t *testing.T) {
ms := &MasterServer{
Cluster: cluster.NewCluster(),
LockRingManager: cluster.NewLockRingManager(nil),
}
registered := &master_pb.KeepConnectedRequest{ClientType: cluster.FilerType, FilerGroup: "group-a"}
register := func(address pb.ServerAddress) {
ms.Cluster.AddClusterNode(registered.FilerGroup, registered.ClientType, "", "", address, "")
ms.updateLockRing(registered, address)
}
ringServers := func() []string {
ms.LockRingManager.FlushPending("group-a")
return ms.LockRingManager.GetServers("group-a")
}
register("filer0:8888")
register("filer1:8888")
register("filer0:8888")
ms.removeClusterNode(registered, "filer0:8888")
assert.True(t, ms.Cluster.IsKnownNode(cluster.FilerType, "filer0:8888"))
assert.ElementsMatch(t, []string{"filer0:8888", "filer1:8888"}, ringServers())
ms.removeClusterNode(registered, "filer0:8888")
assert.False(t, ms.Cluster.IsKnownNode(cluster.FilerType, "filer0:8888"))
assert.ElementsMatch(t, []string{"filer1:8888"}, ringServers())
}
// A leaving filer's reconnect can register before the master ends its broken
// stream; the filer must leave the ring without waiting for either stream.
func TestLeavingFilerReconnectLeavesLockRing(t *testing.T) {
ms := &MasterServer{
Cluster: cluster.NewCluster(),
LockRingManager: cluster.NewLockRingManager(nil),
}
registered := &master_pb.KeepConnectedRequest{ClientType: cluster.FilerType, FilerGroup: "group-a"}
leaving := &master_pb.KeepConnectedRequest{ClientType: cluster.FilerType, FilerGroup: "group-a", LeaveLockRing: true}
register := func(req *master_pb.KeepConnectedRequest, address pb.ServerAddress) {
ms.Cluster.AddClusterNode(req.FilerGroup, req.ClientType, "", "", address, "")
ms.updateLockRing(req, address)
}
ringServers := func() []string {
ms.LockRingManager.FlushPending("group-a")
return ms.LockRingManager.GetServers("group-a")
}
register(registered, "filer0:8888")
register(registered, "filer1:8888")
register(leaving, "filer0:8888")
assert.ElementsMatch(t, []string{"filer1:8888"}, ringServers())
ms.removeClusterNode(registered, "filer0:8888")
assert.True(t, ms.Cluster.IsKnownNode(cluster.FilerType, "filer0:8888"))
assert.ElementsMatch(t, []string{"filer1:8888"}, ringServers())
ms.removeClusterNode(leaving, "filer0:8888")
assert.False(t, ms.Cluster.IsKnownNode(cluster.FilerType, "filer0:8888"))
assert.ElementsMatch(t, []string{"filer1:8888"}, ringServers())
}
// A filer that left the ring and is replaced before its stream ends must not
// take the replacement out of the ring with it.
func TestLeftFilerStreamEndKeepsReplacementInLockRing(t *testing.T) {
ms := &MasterServer{
Cluster: cluster.NewCluster(),
LockRingManager: cluster.NewLockRingManager(nil),
}
registered := &master_pb.KeepConnectedRequest{ClientType: cluster.FilerType, FilerGroup: "group-a"}
for _, address := range []pb.ServerAddress{"filer0:8888", "filer1:8888"} {
ms.Cluster.AddClusterNode(registered.FilerGroup, registered.ClientType, "", "", address, "")
ms.LockRingManager.AddServer(cluster.FilerGroupName(registered.FilerGroup), address)
}
ms.onKeepConnectedMessage(registered, "filer0:8888", &master_pb.KeepConnectedRequest{LeaveLockRing: true})
ms.LockRingManager.FlushPending("group-a")
assert.ElementsMatch(t, []string{"filer1:8888"}, ms.LockRingManager.GetServers("group-a"))
ms.Cluster.AddClusterNode(registered.FilerGroup, registered.ClientType, "", "", "filer0:8888", "")
ms.LockRingManager.AddServer(cluster.FilerGroupName(registered.FilerGroup), "filer0:8888")
ms.removeClusterNode(registered, "filer0:8888")
ms.LockRingManager.FlushPending("group-a")
assert.ElementsMatch(t, []string{"filer0:8888", "filer1:8888"}, ms.LockRingManager.GetServers("group-a"))
}
func TestInitialLockRingUpdateSkipsNonFilers(t *testing.T) {
ms := &MasterServer{
LockRingManager: cluster.NewLockRingManager(nil),
}
ms.LockRingManager.AddServer("group-a", "filer1:8888")
ms.LockRingManager.FlushPending("group-a")
assert.Nil(t, ms.initialLockRingUpdate(cluster.BrokerType, "group-a"))
}
func TestInitialLockRingUpdateReachesLockClients(t *testing.T) {
ms := &MasterServer{
LockRingManager: cluster.NewLockRingManager(nil),
}
ms.LockRingManager.AddServer("group-a", "filer1:8888")
ms.LockRingManager.FlushPending("group-a")
for _, clientType := range []string{cluster.S3Type, cluster.MountType} {
resp := ms.initialLockRingUpdate(clientType, "group-a")
require.NotNil(t, resp, clientType)
assert.Equal(t, []string{"filer1:8888"}, resp.LockRingUpdate.Servers, clientType)
}
}
// TestReconnectedClientSurvivesOldHandlerCleanup covers a client reconnecting
// KeepConnected under the same name before the old handler has exited: the old
// handler's deferred cleanup must not close the channel the reconnected stream
// registered, or that stream reads nil from its closed channel forever and
// floods the client with empty responses.
func TestReconnectedClientSurvivesOldHandlerCleanup(t *testing.T) {
ms := &MasterServer{
clientChans: make(map[string]chan *master_pb.KeepConnectedResponse),
}
clientName, oldChan := ms.addClient("", cluster.MasterType, "peer:19333")
_, newChan := ms.addClient("", cluster.MasterType, "peer:19333")
// the old handler's deferred cleanup runs after the reconnect registered
ms.deleteClient(clientName, oldChan)
// the old channel is closed so its drain goroutine can exit
select {
case _, ok := <-oldChan:
assert.False(t, ok, "old channel should be closed")
default:
t.Fatal("old channel left open")
}
// the reconnected stream's channel is untouched and still receives broadcasts
ms.broadcastToClients(&master_pb.KeepConnectedResponse{
VolumeLocation: &master_pb.VolumeLocation{Url: "volume-a:8080"},
})
select {
case message, ok := <-newChan:
require.True(t, ok, "reconnected client's channel was closed by the old handler's cleanup")
require.NotNil(t, message.GetVolumeLocation())
default:
t.Fatal("no broadcast reached the reconnected client")
}
// the reconnected handler's own cleanup still removes the registration
ms.deleteClient(clientName, newChan)
ms.clientChansLock.RLock()
_, found := ms.clientChans[clientName]
ms.clientChansLock.RUnlock()
assert.False(t, found)
}
// TestBroadcastVolumeLocationsToClients verifies grown volume locations are sent to registered clients.
func TestBroadcastVolumeLocationsToClients(t *testing.T) {
clientChan := make(chan *master_pb.KeepConnectedResponse, 2)
ms := &MasterServer{
clientChans: map[string]chan *master_pb.KeepConnectedResponse{
"default.filer@127.0.0.1:8888": clientChan,
},
}
ms.broadcastVolumeLocationsToClients([]*master_pb.VolumeLocation{
{Url: "volume-a:8080", NewVids: []uint32{7}},
{Url: "volume-b:8080", NewVids: []uint32{8}},
})
var first *master_pb.KeepConnectedResponse
select {
case first = <-clientChan:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for first broadcast")
}
require.NotNil(t, first.GetVolumeLocation())
assert.Equal(t, []uint32{7}, first.GetVolumeLocation().GetNewVids())
assert.Equal(t, "volume-a:8080", first.GetVolumeLocation().GetUrl())
var second *master_pb.KeepConnectedResponse
select {
case second = <-clientChan:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for second broadcast")
}
require.NotNil(t, second.GetVolumeLocation())
assert.Equal(t, []uint32{8}, second.GetVolumeLocation().GetNewVids())
assert.Equal(t, "volume-b:8080", second.GetVolumeLocation().GetUrl())
}