mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-25 16:47:28 +00:00
* volume: start a volume's batch write worker on first use Mounting a volume started a goroutine parked on a 128-slot channel, plus the 128-entry batch slice it had already allocated. That is around 6.7KB per volume the server pays whether or not the volume ever takes a write: 7231 bytes per mounted volume, of which 4101 is goroutine stack. Only a write that asks for fsync ever reaches the worker, and a remote-tiered or read-only volume never can. Create the channel and its goroutine on the first such request instead, and let a write arriving after Destroy fall back to the inline path rather than queue onto a worker that has gone. Measured over 20000 mounted volumes: 7231 -> 1269 bytes each. * volume: update the heartbeat report state in place Every heartbeat built a second map of what it was about to tell the master, holding a freshly allocated short information message per volume, then swapped it in over the old one -- and computed departures through a third map of the live volume ids. A server holding 2M volumes rebuilt all three every VolumePulsePeriod for a report that usually says nothing. Number the heartbeats instead and mark the entry already held with the pass that found the copy, so a quiet volume costs a map lookup and no allocation. Departures are the entries a pass did not mark; the live-id map is now built only when there are some, sized to them. Measured over 10000 mounted volumes: 436 -> 196 bytes allocated per volume per heartbeat. * volume: fill one volume information message per heartbeat, not per volume The heartbeat built a message for every volume held so it could hash it, then dropped all but the few it had something to say about. At 2M volumes that is 2M messages allocated every VolumePulsePeriod to send almost none of them. Fill a message the caller supplies instead, and replace it only when the heartbeat keeps it, so a server with nothing to report fills the same one all the way through. Measured over 10000 mounted volumes: 196 -> 4 bytes allocated per volume per heartbeat, and a heartbeat runs a third faster. * volume: drop the per-volume trace from the heartbeat's status read glog.V(4).Infof evaluates its arguments whether or not the verbosity is on, so every volume boxed its id into a fresh interface slice on every heartbeat: 759 of the 773 allocations a 1000-volume heartbeat made, for a line that at this scale would print millions of unreadable rows. Measured over 1000 mounted volumes: 4776 -> 1792 bytes and 759 -> 14 allocations per heartbeat, which no longer grows with the volume count. * seaweed-volume: mirror the in-place heartbeat report state Same change as the Go volume server: number the heartbeats and mark the entry already held with the pass that found the copy, instead of building a second map of hashes and swapping it in. The volume snapshot must leave the reporting state as it found it, so it keeps asking through changed() while a real heartbeat marks through record(). * volume: refuse writes to a closed volume instead of dereferencing nil Close and Destroy leave the needle map and data backend nil, but a caller that already holds the volume can still reach the write path, where both are used unguarded: a write racing a volume deletion took the server down. syncDelete has always checked; syncWrite and the batch worker had not. Reachable before this series and now also from the inline fallback a durable write takes when the worker has gone. * seaweed-volume: guard the report state with one mutex, as Go does The full-list flag and the generation that answers it have to move together. Split across separate atomics they cannot: a request landing between begin's two reads returns full == false with the generation it just raised, and one landing between commit's read and its clear is marked answered by a heartbeat that carried no list. Either way the resend is dropped. Neither is reachable today -- every caller reaches this through the store's RwLock, the flag setters under a read lock and the heartbeat build under a write lock, so they cannot interleave. The type should not depend on that being true two files away, and Go holds a single mutex over exactly these fields. * test: build the servers under test to match the harness's offset size The mixed Go/Rust suites run both servers against one dataset, so both have to agree on the offset width. They did not: the harness built Go with no tags, 4-byte offsets, while the Rust crate defaults to its 5bytes feature, and the Rust server then refused the .vif the Go server had just written -- "bytes_offset mismatch: found 4, expected 5". Build each side to match the offset size the test binary itself was compiled with, so a plain `go test` and one with -tags 5BytesOffset both get a matched pair.
349 lines
9.3 KiB
Go
349 lines
9.3 KiB
Go
package framework
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/test/testutil"
|
|
"github.com/seaweedfs/seaweedfs/test/volume_server/matrix"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
|
)
|
|
|
|
// RustCluster wraps a Go master + Rust volume server for integration testing.
|
|
type RustCluster struct {
|
|
testingTB testing.TB
|
|
profile matrix.Profile
|
|
|
|
weedBinary string // Go weed binary (for the master)
|
|
rustVolumeBinary string // Rust volume binary
|
|
|
|
baseDir string
|
|
configDir string
|
|
logsDir string
|
|
keepLogs bool
|
|
|
|
masterPort int
|
|
masterGrpcPort int
|
|
volumePort int
|
|
volumeGrpcPort int
|
|
volumePubPort int
|
|
|
|
masterCmd *exec.Cmd
|
|
volumeCmd *exec.Cmd
|
|
|
|
cleanupOnce sync.Once
|
|
}
|
|
|
|
var (
|
|
rustBinaryOnce sync.Once
|
|
rustBinaryPath string
|
|
rustBinaryErr error
|
|
)
|
|
|
|
// StartRustVolumeCluster starts a Go master + Rust volume server.
|
|
func StartRustVolumeCluster(t testing.TB, profile matrix.Profile) *RustCluster {
|
|
t.Helper()
|
|
|
|
weedBinary, err := FindOrBuildWeedBinary()
|
|
if err != nil {
|
|
t.Fatalf("resolve weed binary: %v", err)
|
|
}
|
|
|
|
rustBinary, err := FindOrBuildRustBinary()
|
|
if err != nil {
|
|
t.Fatalf("resolve rust volume binary: %v", err)
|
|
}
|
|
|
|
baseDir, keepLogs, err := newWorkDir()
|
|
if err != nil {
|
|
t.Fatalf("create temp test directory: %v", err)
|
|
}
|
|
|
|
configDir := filepath.Join(baseDir, "config")
|
|
logsDir := filepath.Join(baseDir, "logs")
|
|
masterDataDir := filepath.Join(baseDir, "master")
|
|
volumeDataDir := filepath.Join(baseDir, "volume")
|
|
for _, dir := range []string{configDir, logsDir, masterDataDir, volumeDataDir} {
|
|
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
|
|
t.Fatalf("create %s: %v", dir, mkErr)
|
|
}
|
|
}
|
|
|
|
if err = writeSecurityConfig(configDir, profile); err != nil {
|
|
t.Fatalf("write security config: %v", err)
|
|
}
|
|
|
|
miniPorts, ports, err := testutil.AllocatePortSet(1, 3)
|
|
if err != nil {
|
|
t.Fatalf("allocate ports: %v", err)
|
|
}
|
|
masterPort := miniPorts[0]
|
|
masterGrpcPort := masterPort + testutil.GrpcPortOffset
|
|
|
|
rc := &RustCluster{
|
|
testingTB: t,
|
|
profile: profile,
|
|
weedBinary: weedBinary,
|
|
rustVolumeBinary: rustBinary,
|
|
baseDir: baseDir,
|
|
configDir: configDir,
|
|
logsDir: logsDir,
|
|
keepLogs: keepLogs,
|
|
masterPort: masterPort,
|
|
masterGrpcPort: masterGrpcPort,
|
|
volumePort: ports[0],
|
|
volumeGrpcPort: ports[1],
|
|
volumePubPort: ports[0],
|
|
}
|
|
if profile.SplitPublicPort {
|
|
rc.volumePubPort = ports[2]
|
|
}
|
|
|
|
if err = rc.startMaster(masterDataDir); err != nil {
|
|
rc.Stop()
|
|
t.Fatalf("start master: %v", err)
|
|
}
|
|
// Reuse the same HTTP readiness helper via an unexported Cluster shim.
|
|
helper := &Cluster{logsDir: logsDir}
|
|
if err = helper.waitForHTTP(rc.MasterURL() + "/dir/status"); err != nil {
|
|
masterLog := helper.tailLog("master.log")
|
|
rc.Stop()
|
|
t.Fatalf("wait for master readiness: %v\nmaster log tail:\n%s", err, masterLog)
|
|
}
|
|
|
|
if err = rc.startRustVolume(volumeDataDir); err != nil {
|
|
masterLog := helper.tailLog("master.log")
|
|
rc.Stop()
|
|
t.Fatalf("start rust volume: %v\nmaster log tail:\n%s", err, masterLog)
|
|
}
|
|
if err = helper.waitForHTTP(rc.VolumeAdminURL() + "/healthz"); err != nil {
|
|
volumeLog := helper.tailLog("volume.log")
|
|
rc.Stop()
|
|
t.Fatalf("wait for rust volume readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
|
}
|
|
if err = helper.waitForTCP(rc.VolumeGRPCAddress()); err != nil {
|
|
volumeLog := helper.tailLog("volume.log")
|
|
rc.Stop()
|
|
t.Fatalf("wait for rust volume grpc readiness: %v\nvolume log tail:\n%s", err, volumeLog)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
rc.Stop()
|
|
})
|
|
|
|
return rc
|
|
}
|
|
|
|
// Stop terminates all processes and cleans temporary files.
|
|
func (rc *RustCluster) Stop() {
|
|
if rc == nil {
|
|
return
|
|
}
|
|
rc.cleanupOnce.Do(func() {
|
|
stopProcess(rc.volumeCmd)
|
|
stopProcess(rc.masterCmd)
|
|
if !rc.keepLogs && !rc.testingTB.Failed() {
|
|
_ = os.RemoveAll(rc.baseDir)
|
|
} else if rc.baseDir != "" {
|
|
rc.testingTB.Logf("rust volume server integration logs kept at %s", rc.baseDir)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (rc *RustCluster) startMaster(dataDir string) error {
|
|
logFile, err := os.Create(filepath.Join(rc.logsDir, "master.log"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
args := []string{
|
|
"-config_dir=" + rc.configDir,
|
|
"master",
|
|
"-ip=127.0.0.1",
|
|
"-port=" + strconv.Itoa(rc.masterPort),
|
|
"-port.grpc=" + strconv.Itoa(rc.masterGrpcPort),
|
|
"-mdir=" + dataDir,
|
|
"-peers=none",
|
|
"-volumeSizeLimitMB=" + strconv.Itoa(testVolumeSizeLimitMB),
|
|
"-defaultReplication=000",
|
|
}
|
|
|
|
rc.masterCmd = exec.Command(rc.weedBinary, args...)
|
|
rc.masterCmd.Dir = rc.baseDir
|
|
rc.masterCmd.Stdout = logFile
|
|
rc.masterCmd.Stderr = logFile
|
|
return rc.masterCmd.Start()
|
|
}
|
|
|
|
func rustVolumeArgs(
|
|
profile matrix.Profile,
|
|
configDir string,
|
|
masterPort int,
|
|
volumePort int,
|
|
volumeGrpcPort int,
|
|
volumePubPort int,
|
|
dataDir string,
|
|
) []string {
|
|
args := []string{
|
|
"--port", strconv.Itoa(volumePort),
|
|
"--port.grpc", strconv.Itoa(volumeGrpcPort),
|
|
"--port.public", strconv.Itoa(volumePubPort),
|
|
"--ip", "127.0.0.1",
|
|
"--ip.bind", "127.0.0.1",
|
|
"--dir", dataDir,
|
|
"--max", "16",
|
|
"--master", "127.0.0.1:" + strconv.Itoa(masterPort),
|
|
"--securityFile", filepath.Join(configDir, "security.toml"),
|
|
"--readMode", profile.ReadMode,
|
|
"--concurrentUploadLimitMB", strconv.Itoa(profile.ConcurrentUploadLimitMB),
|
|
"--concurrentDownloadLimitMB", strconv.Itoa(profile.ConcurrentDownloadLimitMB),
|
|
"--preStopSeconds", "0",
|
|
}
|
|
if profile.InflightUploadTimeout > 0 {
|
|
args = append(args, "--inflightUploadDataTimeout", profile.InflightUploadTimeout.String())
|
|
}
|
|
if profile.InflightDownloadTimeout > 0 {
|
|
args = append(args, "--inflightDownloadDataTimeout", profile.InflightDownloadTimeout.String())
|
|
}
|
|
return args
|
|
}
|
|
|
|
func (rc *RustCluster) startRustVolume(dataDir string) error {
|
|
logFile, err := os.Create(filepath.Join(rc.logsDir, "volume.log"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
args := rustVolumeArgs(
|
|
rc.profile,
|
|
rc.configDir,
|
|
rc.masterPort,
|
|
rc.volumePort,
|
|
rc.volumeGrpcPort,
|
|
rc.volumePubPort,
|
|
dataDir,
|
|
)
|
|
|
|
rc.volumeCmd = exec.Command(rc.rustVolumeBinary, args...)
|
|
rc.volumeCmd.Dir = rc.baseDir
|
|
rc.volumeCmd.Stdout = logFile
|
|
rc.volumeCmd.Stderr = logFile
|
|
return rc.volumeCmd.Start()
|
|
}
|
|
|
|
// FindOrBuildRustBinary returns an executable Rust volume binary, building one when needed.
|
|
func FindOrBuildRustBinary() (string, error) {
|
|
if fromEnv := os.Getenv("RUST_VOLUME_BINARY"); fromEnv != "" {
|
|
if isExecutableFile(fromEnv) {
|
|
return fromEnv, nil
|
|
}
|
|
return "", fmt.Errorf("RUST_VOLUME_BINARY is set but not executable: %s", fromEnv)
|
|
}
|
|
|
|
rustBinaryOnce.Do(func() {
|
|
// Derive the Rust volume crate directory from this source file's location.
|
|
rustCrateDir := ""
|
|
if _, file, _, ok := runtime.Caller(0); ok {
|
|
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
|
|
for _, candidate := range []string{"seaweed-volume", "weed-volume"} {
|
|
dir := filepath.Join(repoRoot, candidate)
|
|
if isDir(dir) && isFile(filepath.Join(dir, "Cargo.toml")) {
|
|
rustCrateDir = dir
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if rustCrateDir == "" {
|
|
rustBinaryErr = fmt.Errorf("unable to detect Rust volume crate directory")
|
|
return
|
|
}
|
|
|
|
releaseBin := filepath.Join(rustCrateDir, "target", "release", "weed-volume")
|
|
|
|
// Always rebuild once per test process so the harness uses current source
|
|
// and features. The crate defaults to 5bytes, so a test binary built
|
|
// without 5BytesOffset has to turn it off or the Rust server refuses the
|
|
// .vif the Go server just wrote.
|
|
args := []string{"build", "--release"}
|
|
if types.OffsetSize != 5 {
|
|
args = append(args, "--no-default-features")
|
|
}
|
|
cmd := exec.Command("cargo", args...)
|
|
cmd.Dir = rustCrateDir
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &out
|
|
if err := cmd.Run(); err != nil {
|
|
rustBinaryErr = fmt.Errorf("build rust volume binary: %w\n%s", err, out.String())
|
|
return
|
|
}
|
|
if !isExecutableFile(releaseBin) {
|
|
rustBinaryErr = fmt.Errorf("built rust volume binary is not executable: %s", releaseBin)
|
|
return
|
|
}
|
|
rustBinaryPath = releaseBin
|
|
})
|
|
|
|
if rustBinaryErr != nil {
|
|
return "", rustBinaryErr
|
|
}
|
|
return rustBinaryPath, nil
|
|
}
|
|
|
|
func isDir(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
func isFile(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.Mode().IsRegular()
|
|
}
|
|
|
|
// --- accessor methods (mirror Cluster) ---
|
|
|
|
func (rc *RustCluster) MasterAddress() string {
|
|
return net.JoinHostPort("127.0.0.1", strconv.Itoa(rc.masterPort))
|
|
}
|
|
|
|
func (rc *RustCluster) VolumeAdminAddress() string {
|
|
return net.JoinHostPort("127.0.0.1", strconv.Itoa(rc.volumePort))
|
|
}
|
|
|
|
func (rc *RustCluster) VolumePublicAddress() string {
|
|
return net.JoinHostPort("127.0.0.1", strconv.Itoa(rc.volumePubPort))
|
|
}
|
|
|
|
func (rc *RustCluster) VolumeGRPCAddress() string {
|
|
return net.JoinHostPort("127.0.0.1", strconv.Itoa(rc.volumeGrpcPort))
|
|
}
|
|
|
|
// VolumeServerAddress returns SeaweedFS server address format: ip:httpPort.grpcPort
|
|
func (rc *RustCluster) VolumeServerAddress() string {
|
|
return fmt.Sprintf("%s.%d", rc.VolumeAdminAddress(), rc.volumeGrpcPort)
|
|
}
|
|
|
|
func (rc *RustCluster) MasterURL() string {
|
|
return "http://" + rc.MasterAddress()
|
|
}
|
|
|
|
func (rc *RustCluster) VolumeAdminURL() string {
|
|
return "http://" + rc.VolumeAdminAddress()
|
|
}
|
|
|
|
func (rc *RustCluster) VolumePublicURL() string {
|
|
return "http://" + rc.VolumePublicAddress()
|
|
}
|
|
|
|
func (rc *RustCluster) BaseDir() string {
|
|
return rc.baseDir
|
|
}
|