Files
seaweedfs/test/fuse_failover/framework_test.go
T
Chris LuandGitHub 1ddec72707 Recover from a dead volume server on the mount read path (#10798)
* mount: re-resolve volume locations after a failed chunk read

NewChunkGroup passed nil as the ReaderCache's CacheInvalidator, so
retryFetchAfterCacheInvalidation was dead code on the FUSE read path. A
mount that cached a volume's locations while one server was down kept
retrying that server after it died, then returned EIO, even though the
master and filer both resolved the live replica. The S3 gateway already
passes its filerClient; do the same for the mount.

* test: FUSE integration tests for volume server failover

One mount appends while a second tails, and a volume server is killed,
started or restarted mid-stream against a 001-replicated cluster of three
volume servers. Automates the scenario matrix reported for Docker Swarm
mounts, including the large-file variant and a no-chaos control.

* test: report the filer's own view when append content mismatches

A mismatch between what the writer wrote and what the reader sees can come
from either side's cache. Read the file back through the filer's HTTP
handler as well, and let the mount verbosity be raised from the
environment, so a failing run says which layer lost the data.

* test: wait for the reader mount to converge before comparing

A mount caches metadata for about a second, so reading the file the instant
the writer's last close returned can legitimately come back short. Poll the
reader until it matches or the timeout expires; content that is wrong rather
than merely late never converges and still fails, now with the writer's
mount and the filer's own view alongside it.

* test: detect a failover cluster child that exited at startup

Signal(0) succeeds for a zombie and nothing reaped these children until
shutdown, so a process that died on startup looked alive until the readiness
timeout expired. Reap each child as it is started and consult the result.

* test: read a file the killed volume server actually holds

Placement decides which two of three servers back each volume, so killing
volume N and reading readfile-N could pass without the victim ever holding a
replica of it. Resolve each file's volumes through the filer and the master,
and pick one the victim backs, preferring a file the reader has not cached.

* ci: stop persisting checkout credentials in the failover workflow

The job does not use the token after cloning. Also tag the README's command
block as bash and match the timeout the workflow actually uses.

* test: discard the ignored errors errcheck flags in the failover harness

* test: resolve manifests when mapping a file to its volumes

A manifest chunk's own fid names the volume holding the manifest, not the
volumes holding the data, so a large enough file would point the failover
victim at the wrong server.

* test: pin the stale-location recovery path with a primed reader

Reading a file for the first time after a server dies proves nothing: the
lookup is fresh and returns the survivor. Kill one holder and wait for the
master to drop it, read a file on that volume so the reader caches the lone
survivor, restart the first server, then kill the survivor. The reader's only
cached location is now dead while the data is live elsewhere, which is the
case the invalidator exists for: EIO without it, recovery with it.
2026-08-17 17:30:33 -07:00

538 lines
16 KiB
Go

//go:build linux || darwin
package fuse_failover
import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/test/testutil"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/stretchr/testify/require"
)
// failoverCluster runs 1 master, N volume servers, 1 filer and M FUSE mounts,
// with the master defaulting to 001 replication so every chunk lands on two
// distinct volume servers. Individual volume servers can be stopped and started
// while IO is in flight, which is what the Docker Swarm reports in discussion
// 10206 exercise: a volume server disappears mid-append and the mounts must
// keep reading from the surviving replica and keep writing on another volume.
type failoverCluster struct {
t testing.TB
baseDir string
weedBinary string
masterPort int
masterGrpcPort int
filerPort int
filerGrpcPort int
volumePorts []int
volumeGrpcPort []int
volumeDirs []string
masterCmd *exec.Cmd
filerCmd *exec.Cmd
volumeCmds []*exec.Cmd
mountCmds []*exec.Cmd
mountPoints []string
logFiles []*os.File
mu sync.Mutex
waits map[*exec.Cmd]chan error
cleanupOnce sync.Once
}
func startFailoverCluster(t testing.TB, numVolumes, numMounts int) *failoverCluster {
require.GreaterOrEqual(t, numVolumes, 2, "001 replication needs at least 2 volume servers")
require.GreaterOrEqual(t, numMounts, 1)
binary := findWeedBinary()
if binary == "" {
t.Skip("weed binary not found; set WEED_BINARY or ensure it is on PATH")
}
baseDir, err := os.MkdirTemp("", "seaweedfs_fuse_failover_")
require.NoError(t, err)
c := &failoverCluster{
t: t,
baseDir: baseDir,
weedBinary: binary,
volumePorts: make([]int, numVolumes),
volumeGrpcPort: make([]int, numVolumes),
volumeDirs: make([]string, numVolumes),
volumeCmds: make([]*exec.Cmd, numVolumes),
mountCmds: make([]*exec.Cmd, numMounts),
mountPoints: make([]string, numMounts),
}
t.Cleanup(c.Stop)
ports, err := testutil.AllocatePorts(4 + 2*numVolumes)
require.NoError(t, err)
c.masterPort, c.masterGrpcPort = ports[0], ports[1]
c.filerPort, c.filerGrpcPort = ports[2], ports[3]
for i := 0; i < numVolumes; i++ {
c.volumePorts[i] = ports[4+2*i]
c.volumeGrpcPort[i] = ports[5+2*i]
c.volumeDirs[i] = filepath.Join(baseDir, fmt.Sprintf("volume%d", i))
require.NoError(t, os.MkdirAll(c.volumeDirs[i], 0755))
}
require.NoError(t, c.startMaster())
require.NoError(t, c.waitForTCP(c.masterCmd, "master",
fmt.Sprintf("127.0.0.1:%d", c.masterPort), 30*time.Second))
for i := 0; i < numVolumes; i++ {
require.NoError(t, c.StartVolume(i))
}
require.NoError(t, c.startFiler())
require.NoError(t, c.waitForTCP(c.filerCmd, "filer",
fmt.Sprintf("127.0.0.1:%d", c.filerGrpcPort), 30*time.Second))
for i := 0; i < numMounts; i++ {
mp := filepath.Join(baseDir, fmt.Sprintf("mount%d", i))
require.NoError(t, os.MkdirAll(mp, 0755))
c.mountPoints[i] = mp
require.NoError(t, c.startMount(i))
require.NoError(t, c.waitForMount(mp, 30*time.Second),
"mount %d not ready\n%s", i, c.tailLog(fmt.Sprintf("mount%d", i)))
}
return c
}
func (c *failoverCluster) MountDir(i int) string { return c.mountPoints[i] }
func (c *failoverCluster) Stop() {
if c == nil {
return
}
c.cleanupOnce.Do(func() {
for i := len(c.mountCmds) - 1; i >= 0; i-- {
c.stopCmd(c.mountCmds[i], syscall.SIGTERM)
_ = exec.Command("fusermount3", "-u", c.mountPoints[i]).Run()
_ = exec.Command("fusermount", "-u", c.mountPoints[i]).Run()
}
c.stopCmd(c.filerCmd, syscall.SIGTERM)
for i := len(c.volumeCmds) - 1; i >= 0; i-- {
c.stopCmd(c.volumeCmds[i], syscall.SIGTERM)
}
c.stopCmd(c.masterCmd, syscall.SIGTERM)
c.mu.Lock()
for _, f := range c.logFiles {
_ = f.Close()
}
c.mu.Unlock()
c.copyLogsForCI()
if !c.t.Failed() {
os.RemoveAll(c.baseDir)
}
})
}
// KillVolume drops a volume server without letting it deregister, the closest
// local equivalent of a Swarm task vanishing from the overlay network.
func (c *failoverCluster) KillVolume(i int) {
c.stopCmd(c.volumeCmds[i], syscall.SIGKILL)
c.volumeCmds[i] = nil
}
// StartVolume (re)starts volume server i on its original ports and data dir.
func (c *failoverCluster) StartVolume(i int) error {
cmd := exec.Command(c.weedBinary,
"-logdir="+filepath.Join(c.baseDir, "logs"),
"volume",
"-ip=127.0.0.1",
"-ip.bind=127.0.0.1",
"-port="+strconv.Itoa(c.volumePorts[i]),
"-port.grpc="+strconv.Itoa(c.volumeGrpcPort[i]),
"-master="+c.masterAddress(),
"-dir="+c.volumeDirs[i],
"-dataCenter=dc1",
"-rack=rack1",
"-max=10",
)
c.volumeCmds[i] = cmd
if err := c.startCmd(cmd, fmt.Sprintf("volume%d", i)); err != nil {
return err
}
return c.waitForTCP(cmd, fmt.Sprintf("volume%d", i),
fmt.Sprintf("127.0.0.1:%d", c.volumePorts[i]), 30*time.Second)
}
func (c *failoverCluster) startMaster() error {
c.masterCmd = exec.Command(c.weedBinary,
"-logdir="+filepath.Join(c.baseDir, "logs"),
"master",
"-ip=127.0.0.1",
"-ip.bind=127.0.0.1",
"-port="+strconv.Itoa(c.masterPort),
"-port.grpc="+strconv.Itoa(c.masterGrpcPort),
"-mdir="+filepath.Join(c.baseDir, "master"),
"-defaultReplication=001",
"-volumeSizeLimitMB=64",
)
return c.startCmd(c.masterCmd, "master")
}
func (c *failoverCluster) startFiler() error {
filerDir := filepath.Join(c.baseDir, "filer")
if err := os.MkdirAll(filerDir, 0755); err != nil {
return fmt.Errorf("create filer dir: %w", err)
}
c.filerCmd = exec.Command(c.weedBinary,
"-logdir="+filepath.Join(c.baseDir, "logs"),
"filer",
"-ip=127.0.0.1",
"-ip.bind=127.0.0.1",
"-port="+strconv.Itoa(c.filerPort),
"-port.grpc="+strconv.Itoa(c.filerGrpcPort),
"-master="+c.masterAddress(),
"-defaultReplicaPlacement=001",
"-defaultStoreDir="+filerDir,
)
return c.startCmd(c.filerCmd, "filer")
}
func (c *failoverCluster) startMount(idx int) error {
cacheDir := filepath.Join(c.baseDir, fmt.Sprintf("cache%d", idx))
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("create cache dir: %w", err)
}
// Chunk-level detail needs -v=4; keep CI at -v=2 so the logs stay small.
verbosity := os.Getenv("FUSE_FAILOVER_MOUNT_V")
if verbosity == "" {
verbosity = "2"
}
c.mountCmds[idx] = exec.Command(c.weedBinary,
"-logdir="+filepath.Join(c.baseDir, "logs"),
"-v="+verbosity,
"mount",
"-filer="+c.filerAddress(),
"-dir="+c.mountPoints[idx],
"-filer.path=/",
"-dirAutoCreate",
"-allowOthers=false",
"-replication=001",
"-cacheDir="+cacheDir,
)
return c.startCmd(c.mountCmds[idx], fmt.Sprintf("mount%d", idx))
}
// MasterGet fetches a master HTTP endpoint, e.g. "/dir/status?pretty=y" or
// "/dir/lookup?volumeId=6", so a failing test can show where the replicas are.
func (c *failoverCluster) MasterGet(path string) string {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d%s", c.masterPort, path))
if err != nil {
return fmt.Sprintf("(master %s failed: %v)", path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Sprintf("(master %s read failed: %v)", path, err)
}
return string(body)
}
// FilerGet reads a file back through the filer's own HTTP handler: a view of
// the chunk list that neither mount's cache can colour.
func (c *failoverCluster) FilerGet(path string) ([]byte, error) {
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d%s", c.filerPort, path))
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("filer %s: %s", path, resp.Status)
}
return body, nil
}
// VolumeServerAddress is the address volume server i registers with the master.
func (c *failoverCluster) VolumeServerAddress(i int) string {
return fmt.Sprintf("127.0.0.1:%d", c.volumePorts[i])
}
// FileVolumeIds returns the volume ids backing a file, read from the filer's
// own entry rather than inferred, so a test can tell which servers a given
// file actually depends on. Manifests are resolved first: a manifest chunk's
// own fid names the volume holding the manifest, not the data.
func (c *failoverCluster) FileVolumeIds(path string) ([]uint32, error) {
body, err := c.FilerGet(path + "?metadata=true&resolveManifest=true")
if err != nil {
return nil, err
}
var entry struct {
Chunks []struct {
FileId string `json:"file_id"`
Fid struct {
VolumeId uint32 `json:"volume_id"`
} `json:"fid"`
} `json:"chunks"`
}
if err = json.Unmarshal(body, &entry); err != nil {
return nil, fmt.Errorf("decode entry %s: %w", path, err)
}
seen := make(map[uint32]bool)
var vids []uint32
for _, chunk := range entry.Chunks {
vid := chunk.Fid.VolumeId
if vid == 0 && chunk.FileId != "" {
parsed, parseErr := strconv.ParseUint(strings.SplitN(chunk.FileId, ",", 2)[0], 10, 32)
if parseErr != nil {
return nil, fmt.Errorf("parse file id %s: %w", chunk.FileId, parseErr)
}
vid = uint32(parsed)
}
if !seen[vid] {
seen[vid] = true
vids = append(vids, vid)
}
}
return vids, nil
}
// VolumeHolders returns the volume server addresses the master currently lists
// for a volume id.
func (c *failoverCluster) VolumeHolders(vid uint32) ([]string, error) {
var lookup struct {
Locations []struct {
Url string `json:"url"`
} `json:"locations"`
}
body := c.MasterGet(fmt.Sprintf("/dir/lookup?volumeId=%d", vid))
if err := json.Unmarshal([]byte(body), &lookup); err != nil {
return nil, fmt.Errorf("decode lookup for volume %d: %w (%s)", vid, err, body)
}
holders := make([]string, 0, len(lookup.Locations))
for _, loc := range lookup.Locations {
holders = append(holders, loc.Url)
}
return holders, nil
}
// WaitForHolders polls the master until it lists exactly count servers for a
// volume. The master only drops a dead node after three missed heartbeats, so a
// test that depends on the cluster's view having caught up has to wait for it.
func (c *failoverCluster) WaitForHolders(vid uint32, count int, timeout time.Duration) ([]string, error) {
deadline := time.Now().Add(timeout)
for {
holders, err := c.VolumeHolders(vid)
if err == nil && len(holders) == count {
return holders, nil
}
if time.Now().After(deadline) {
return holders, fmt.Errorf("volume %d still has %d holders (%v), want %d", vid, len(holders), holders, count)
}
time.Sleep(500 * time.Millisecond)
}
}
// volumeIndexOf maps a server address back to its index, or -1.
func (c *failoverCluster) volumeIndexOf(address string) int {
for i := range c.volumePorts {
if c.VolumeServerAddress(i) == address {
return i
}
}
return -1
}
// FileIsOn reports whether any of path's chunks live on the given volume
// server, i.e. whether taking that server down actually costs this file a
// replica.
func (c *failoverCluster) FileIsOn(path, serverAddress string) (bool, error) {
vids, err := c.FileVolumeIds(path)
if err != nil {
return false, err
}
for _, vid := range vids {
holders, holdersErr := c.VolumeHolders(vid)
if holdersErr != nil {
return false, holdersErr
}
for _, holder := range holders {
if holder == serverAddress {
return true, nil
}
}
}
return false, nil
}
func (c *failoverCluster) masterAddress() string {
return string(pb.NewServerAddress("127.0.0.1", c.masterPort, c.masterGrpcPort))
}
func (c *failoverCluster) filerAddress() string {
return string(pb.NewServerAddress("127.0.0.1", c.filerPort, c.filerGrpcPort))
}
func (c *failoverCluster) startCmd(cmd *exec.Cmd, name string) error {
logPath := filepath.Join(c.baseDir, "logs")
if err := os.MkdirAll(logPath, 0755); err != nil {
return fmt.Errorf("create log dir: %w", err)
}
// Append so a restarted volume server keeps the log of its earlier run.
logFile, err := os.OpenFile(filepath.Join(logPath, name+".log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
c.mu.Lock()
c.logFiles = append(c.logFiles, logFile)
c.mu.Unlock()
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
return err
}
// Reap in the background and publish the result: Signal(0) succeeds for a
// zombie, so an unreaped child that died at startup would otherwise look
// alive until the readiness timeout expired.
ch := make(chan error, 1)
c.mu.Lock()
if c.waits == nil {
c.waits = make(map[*exec.Cmd]chan error)
}
c.waits[cmd] = ch
c.mu.Unlock()
go func() {
ch <- cmd.Wait()
close(ch)
}()
return nil
}
// waitChan returns the channel carrying cmd's exit, or nil if it was never
// started through startCmd. It stays readable after the exit is consumed.
func (c *failoverCluster) waitChan(cmd *exec.Cmd) chan error {
if cmd == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
return c.waits[cmd]
}
func (c *failoverCluster) tailLog(name string) string {
data, err := os.ReadFile(filepath.Join(c.baseDir, "logs", name+".log"))
if err != nil {
return fmt.Sprintf("(log %s not available: %v)", name, err)
}
const maxTail = 8192
if len(data) > maxTail {
data = data[len(data)-maxTail:]
}
return string(data)
}
func (c *failoverCluster) copyLogsForCI() {
// One directory per test: subtests share a log dir name otherwise, and the
// last one to finish would overwrite the logs of the one that failed.
ciLogDir := filepath.Join("/tmp/seaweedfs-fuse-failover-logs",
strings.ReplaceAll(c.t.Name(), "/", "_"))
os.MkdirAll(ciLogDir, 0755)
entries, err := os.ReadDir(filepath.Join(c.baseDir, "logs"))
if err != nil {
return
}
for _, e := range entries {
data, err := os.ReadFile(filepath.Join(c.baseDir, "logs", e.Name()))
if err != nil {
continue
}
os.WriteFile(filepath.Join(ciLogDir, e.Name()), data, 0644)
}
}
func (c *failoverCluster) waitForTCP(cmd *exec.Cmd, name, addr string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, time.Second)
if err == nil {
conn.Close()
return nil
}
if ch := c.waitChan(cmd); ch != nil {
select {
case waitErr := <-ch:
return fmt.Errorf("%s exited before listening on %s: %v\n%s",
name, addr, waitErr, c.tailLog(name))
default:
}
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("service at %s not ready within timeout\n%s", addr, c.tailLog(name))
}
func (c *failoverCluster) waitForMount(mountPoint string, timeout time.Duration) error {
parentDir := filepath.Dir(mountPoint)
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
parentStat, err := os.Stat(parentDir)
if err != nil {
time.Sleep(200 * time.Millisecond)
continue
}
mountStat, err := os.Stat(mountPoint)
if err != nil {
time.Sleep(200 * time.Millisecond)
continue
}
if parentStat.Sys().(*syscall.Stat_t).Dev != mountStat.Sys().(*syscall.Stat_t).Dev {
return nil
}
time.Sleep(200 * time.Millisecond)
}
return fmt.Errorf("mount point %s not ready within timeout (FUSE not detected)", mountPoint)
}
func findWeedBinary() string {
if env := os.Getenv("WEED_BINARY"); env != "" {
if _, err := os.Stat(env); err == nil {
return env
}
}
if p, err := exec.LookPath("weed"); err == nil {
return p
}
return ""
}
// stopCmd signals cmd and waits for the reaper goroutine started by startCmd to
// report its exit, escalating to SIGKILL if it does not go quietly.
func (c *failoverCluster) stopCmd(cmd *exec.Cmd, sig syscall.Signal) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Signal(sig)
done := c.waitChan(cmd)
if done == nil {
return
}
select {
case <-done:
case <-time.After(10 * time.Second):
_ = cmd.Process.Signal(syscall.SIGKILL)
<-done
}
}