Files
seaweedfs/weed/storage/blockvol/test/component/cluster.go
T
pingqiuandClaude Opus 4.6 785a7d7efd feat: wire real pinner into flusher retention + real WAL scan executor (Phase 07 P1)
Pinner wired to real retention:
- NewPinner calls vol.SetV2RetentionFloor(p.MinWALRetentionFloor)
- Flusher.RetentionFloorFn() / SetRetentionFloorFn() exposed
- SetV2RetentionFloor chains with existing shipper retention floor
- Holds actually prevent WAL reclaim (not just tracked state)

Executor uses real WAL scan:
- BlockVol.ScanWALEntries(fromLSN, callback) wraps wal.ScanFrom
  with real fd, walOffset, checkpointLSN
- Executor.StreamWALEntries uses ScanWALEntries (not stub)
- Reads real WAL entries, tracks highest LSN scanned

CommittedLSN mapping:
- Explicitly documented as interim V1 model (committed = checkpointed)
- Will diverge when V2 distributed commit separates from local flush

Carry-forward:
- TransferSnapshot/TransferFullBase/TruncateWAL: stubs (need extent I/O)
- Control intent from confirmed failover: deferred

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:01:46 -07:00

309 lines
7.8 KiB
Go

//go:build integration
// Package component provides component-level integration tests for the block
// storage control plane. Tests start real weed master + volume server processes
// on localhost, exercise the HTTP API via blockapi.Client, and verify registry
// state. No SSH, no kernel iSCSI, no special hardware.
//
// Run: go test -tags integration -v -timeout 10m ./weed/storage/blockvol/test/component/
// Or: WEED_BINARY=/path/to/weed go test -tags integration ...
package component
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
)
// cluster manages a weed master + N volume servers for component testing.
type cluster struct {
t *testing.T
weedBin string
masterPort int
ip string
masterDir string
masterCmd *exec.Cmd
masterLog *os.File
volumes []*volumeProc
}
type volumeProc struct {
idx int
port int
blockPort int
dir string
extraArgs []string
cmd *exec.Cmd
logFd *os.File
stopped bool
}
// newCluster creates a cluster helper. Cleanup is registered via t.Cleanup.
func newCluster(t *testing.T, weedBin string, masterPort int) *cluster {
t.Helper()
dir, err := os.MkdirTemp("", "sw-comp-master-")
if err != nil {
t.Fatal(err)
}
c := &cluster{
t: t,
weedBin: weedBin,
masterPort: masterPort,
ip: "127.0.0.1",
masterDir: dir,
}
t.Cleanup(func() {
c.stop()
if t.Failed() {
c.dumpLogs()
}
})
return c
}
// addVolume registers a volume server to start. Returns its index.
// Optional extraArgs are appended to the weed volume command line.
func (c *cluster) addVolume(port, blockPort int, extraArgs ...string) int {
c.t.Helper()
dir, err := os.MkdirTemp("", fmt.Sprintf("sw-comp-vs%d-", len(c.volumes)))
if err != nil {
c.t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "blocks"), 0755); err != nil {
c.t.Fatal(err)
}
idx := len(c.volumes)
c.volumes = append(c.volumes, &volumeProc{
idx: idx, port: port, blockPort: blockPort, dir: dir, extraArgs: extraArgs,
})
return idx
}
// start launches master + all volume servers and waits for readiness.
func (c *cluster) start(ctx context.Context) {
c.t.Helper()
// Start master.
c.masterCmd = exec.Command(c.weedBin, "master",
fmt.Sprintf("-port=%d", c.masterPort),
fmt.Sprintf("-mdir=%s", c.masterDir),
)
logPath := filepath.Join(c.masterDir, "master.log")
f, err := os.Create(logPath)
if err != nil {
c.t.Fatal(err)
}
c.masterLog = f
c.masterCmd.Stdout = f
c.masterCmd.Stderr = f
if err := c.masterCmd.Start(); err != nil {
f.Close()
c.t.Fatalf("start master: %v", err)
}
// Wait for master to become leader.
c.waitClusterReady(ctx, 30*time.Second)
// Start volume servers.
for _, vs := range c.volumes {
c.startVolumeAt(ctx, vs)
}
}
func (c *cluster) startVolumeAt(ctx context.Context, vs *volumeProc) {
args := []string{"volume",
fmt.Sprintf("-port=%d", vs.port),
fmt.Sprintf("-mserver=%s:%d", c.ip, c.masterPort),
fmt.Sprintf("-dir=%s", vs.dir),
fmt.Sprintf("-block.dir=%s", filepath.Join(vs.dir, "blocks")),
fmt.Sprintf("-block.listen=:%d", vs.blockPort),
fmt.Sprintf("-ip=%s", c.ip),
}
args = append(args, vs.extraArgs...)
vs.cmd = exec.Command(c.weedBin, args...)
logPath := filepath.Join(vs.dir, "volume.log")
f, err := os.Create(logPath)
if err != nil {
c.t.Fatal(err)
}
vs.logFd = f
vs.cmd.Stdout = f
vs.cmd.Stderr = f
if err := vs.cmd.Start(); err != nil {
f.Close()
c.t.Fatalf("start volume server %d: %v", vs.idx, err)
}
vs.stopped = false
}
// client returns a blockapi.Client pointing at the master.
func (c *cluster) client() *blockapi.Client {
return blockapi.NewClient(fmt.Sprintf("http://%s:%d", c.ip, c.masterPort))
}
// waitClusterReady polls /cluster/status until IsLeader is true.
func (c *cluster) waitClusterReady(ctx context.Context, timeout time.Duration) {
c.t.Helper()
deadline := time.After(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
url := fmt.Sprintf("http://%s:%d/cluster/status", c.ip, c.masterPort)
for {
select {
case <-deadline:
c.t.Fatalf("master not ready after %s", timeout)
case <-ctx.Done():
c.t.Fatal("context cancelled waiting for master")
case <-ticker.C:
resp, err := http.Get(url)
if err != nil {
continue
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if strings.Contains(string(body), `"IsLeader":true`) ||
strings.Contains(string(body), `"isLeader":true`) {
return
}
}
}
}
// waitBlockServers polls until count block-capable servers are registered.
func (c *cluster) waitBlockServers(ctx context.Context, count int, timeout time.Duration) {
c.t.Helper()
cl := c.client()
deadline := time.After(timeout)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
c.t.Fatalf("wanted %d block servers, timed out after %s", count, timeout)
case <-ctx.Done():
c.t.Fatal("context cancelled waiting for block servers")
case <-ticker.C:
servers, err := cl.ListServers(ctx)
if err != nil {
continue
}
capable := 0
for _, s := range servers {
if s.BlockCapable {
capable++
}
}
if capable >= count {
return
}
}
}
}
// waitPrimaryChange polls until the volume's primary differs from notServer.
func (c *cluster) waitPrimaryChange(ctx context.Context, name, notServer string, timeout time.Duration) *blockapi.VolumeInfo {
c.t.Helper()
cl := c.client()
deadline := time.After(timeout)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
c.t.Fatalf("primary for %s didn't change from %s after %s", name, notServer, timeout)
case <-ctx.Done():
c.t.Fatalf("context cancelled waiting for primary change on %s", name)
case <-ticker.C:
info, err := cl.LookupVolume(ctx, name)
if err != nil {
continue
}
if info.VolumeServer != notServer && info.VolumeServer != "" {
return info
}
}
}
}
// stopVolume kills a volume server by index.
func (c *cluster) stopVolume(idx int) {
vs := c.volumes[idx]
if vs.stopped || vs.cmd == nil || vs.cmd.Process == nil {
return
}
vs.cmd.Process.Kill()
vs.cmd.Wait()
if vs.logFd != nil {
vs.logFd.Close()
vs.logFd = nil
}
vs.stopped = true
}
// restartVolume starts a previously stopped volume server with the same params.
func (c *cluster) restartVolume(ctx context.Context, idx int) {
c.t.Helper()
vs := c.volumes[idx]
if !vs.stopped {
c.t.Fatalf("volume %d not stopped", idx)
}
c.startVolumeAt(ctx, vs)
}
// stop kills all processes and removes temp dirs.
func (c *cluster) stop() {
for _, vs := range c.volumes {
if !vs.stopped && vs.cmd != nil && vs.cmd.Process != nil {
vs.cmd.Process.Kill()
vs.cmd.Wait()
}
if vs.logFd != nil {
vs.logFd.Close()
}
os.RemoveAll(vs.dir)
}
if c.masterCmd != nil && c.masterCmd.Process != nil {
c.masterCmd.Process.Kill()
c.masterCmd.Wait()
}
if c.masterLog != nil {
c.masterLog.Close()
}
os.RemoveAll(c.masterDir)
}
// dumpLogs prints process logs (called on test failure).
func (c *cluster) dumpLogs() {
logPath := filepath.Join(c.masterDir, "master.log")
if data, err := os.ReadFile(logPath); err == nil && len(data) > 0 {
// Truncate to last 200 lines.
lines := strings.Split(string(data), "\n")
if len(lines) > 200 {
lines = lines[len(lines)-200:]
}
c.t.Logf("=== Master log (last %d lines) ===\n%s", len(lines), strings.Join(lines, "\n"))
}
for i, vs := range c.volumes {
logPath := filepath.Join(vs.dir, "volume.log")
if data, err := os.ReadFile(logPath); err == nil && len(data) > 0 {
lines := strings.Split(string(data), "\n")
if len(lines) > 200 {
lines = lines[len(lines)-200:]
}
c.t.Logf("=== Volume %d log (last %d lines) ===\n%s", i, len(lines), strings.Join(lines, "\n"))
}
}
}