Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
793 lines
24 KiB
Go
793 lines
24 KiB
Go
package weed_server
|
||
|
||
import (
|
||
"context"
|
||
"io"
|
||
"net"
|
||
"sync"
|
||
"sync/atomic"
|
||
"syscall"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/grpc/credentials/insecure"
|
||
)
|
||
|
||
// fakeFilerServer simulates a filer whose CreateEntry takes a fixed amount of
|
||
// wall-clock time (meant to model the filer-store commit latency). It implements
|
||
// only what these benchmarks need: unary CreateEntry and bidi StreamMutateEntry.
|
||
//
|
||
// The StreamMutateEntry handler mirrors the production handler's structure:
|
||
// a single goroutine per stream, processing one request at a time.
|
||
type fakeFilerServer struct {
|
||
filer_pb.UnimplementedSeaweedFilerServer
|
||
serviceDelay time.Duration
|
||
createCalls atomic.Int64
|
||
}
|
||
|
||
func (s *fakeFilerServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||
s.createCalls.Add(1)
|
||
if s.serviceDelay > 0 {
|
||
time.Sleep(s.serviceDelay)
|
||
}
|
||
return &filer_pb.CreateEntryResponse{}, nil
|
||
}
|
||
|
||
// StreamMutateEntry mirrors the OLD serial handler: one goroutine, strictly
|
||
// one request at a time.
|
||
func (s *fakeFilerServer) StreamMutateEntry(stream grpc.BidiStreamingServer[filer_pb.StreamMutateEntryRequest, filer_pb.StreamMutateEntryResponse]) error {
|
||
for {
|
||
req, err := stream.Recv()
|
||
if err == io.EOF {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
switch r := req.Request.(type) {
|
||
case *filer_pb.StreamMutateEntryRequest_CreateRequest:
|
||
resp, createErr := s.CreateEntry(stream.Context(), r.CreateRequest)
|
||
if createErr != nil {
|
||
resp = &filer_pb.CreateEntryResponse{Error: createErr.Error()}
|
||
}
|
||
out := &filer_pb.StreamMutateEntryResponse{
|
||
RequestId: req.RequestId,
|
||
IsLast: true,
|
||
Response: &filer_pb.StreamMutateEntryResponse_CreateResponse{CreateResponse: resp},
|
||
}
|
||
if resp.Error != "" {
|
||
out.Error = resp.Error
|
||
out.Errno = int32(syscall.EIO)
|
||
}
|
||
if sendErr := stream.Send(out); sendErr != nil {
|
||
return sendErr
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// fakeConcurrentFilerServer mirrors the NEW concurrent handler: per-request
|
||
// goroutine, bounded by a semaphore, with a Send mutex for stream safety.
|
||
// Structurally identical to weed/server/filer_grpc_server_stream_mutate.go
|
||
// after the parallelization patch.
|
||
type fakeConcurrentFilerServer struct {
|
||
filer_pb.UnimplementedSeaweedFilerServer
|
||
serviceDelay time.Duration
|
||
concurrency int
|
||
createCalls atomic.Int64
|
||
}
|
||
|
||
func (s *fakeConcurrentFilerServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||
s.createCalls.Add(1)
|
||
if s.serviceDelay > 0 {
|
||
time.Sleep(s.serviceDelay)
|
||
}
|
||
return &filer_pb.CreateEntryResponse{}, nil
|
||
}
|
||
|
||
func (s *fakeConcurrentFilerServer) StreamMutateEntry(stream grpc.BidiStreamingServer[filer_pb.StreamMutateEntryRequest, filer_pb.StreamMutateEntryResponse]) error {
|
||
var sendMu sync.Mutex
|
||
send := func(r *filer_pb.StreamMutateEntryResponse) error {
|
||
sendMu.Lock()
|
||
defer sendMu.Unlock()
|
||
return stream.Send(r)
|
||
}
|
||
sem := make(chan struct{}, s.concurrency)
|
||
var wg sync.WaitGroup
|
||
for {
|
||
req, err := stream.Recv()
|
||
if err == io.EOF {
|
||
wg.Wait()
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
wg.Wait()
|
||
return err
|
||
}
|
||
sem <- struct{}{}
|
||
wg.Add(1)
|
||
go func(req *filer_pb.StreamMutateEntryRequest) {
|
||
defer wg.Done()
|
||
defer func() { <-sem }()
|
||
switch r := req.Request.(type) {
|
||
case *filer_pb.StreamMutateEntryRequest_CreateRequest:
|
||
resp, _ := s.CreateEntry(stream.Context(), r.CreateRequest)
|
||
_ = send(&filer_pb.StreamMutateEntryResponse{
|
||
RequestId: req.RequestId,
|
||
IsLast: true,
|
||
Response: &filer_pb.StreamMutateEntryResponse_CreateResponse{CreateResponse: resp},
|
||
})
|
||
}
|
||
}(req)
|
||
}
|
||
}
|
||
|
||
// startFakeConcurrentFilerServer spins up the concurrent-handler variant.
|
||
func startFakeConcurrentFilerServer(t testing.TB, serviceDelay time.Duration, concurrency int) (string, *fakeConcurrentFilerServer, func()) {
|
||
t.Helper()
|
||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatalf("listen: %v", err)
|
||
}
|
||
srv := grpc.NewServer()
|
||
fake := &fakeConcurrentFilerServer{serviceDelay: serviceDelay, concurrency: concurrency}
|
||
filer_pb.RegisterSeaweedFilerServer(srv, fake)
|
||
go srv.Serve(lis)
|
||
return lis.Addr().String(), fake, func() {
|
||
srv.GracefulStop()
|
||
_ = lis.Close()
|
||
}
|
||
}
|
||
|
||
// fakeSchedulerFilerServer uses the real mutateScheduler from the production
|
||
// handler to admit requests by (path, kind). Per-path operations serialize;
|
||
// cross-path operations run in parallel.
|
||
type fakeSchedulerFilerServer struct {
|
||
filer_pb.UnimplementedSeaweedFilerServer
|
||
serviceDelay time.Duration
|
||
concurrency int
|
||
createCalls atomic.Int64
|
||
maxInFlight atomic.Int64 // sampled peak of in-flight create goroutines
|
||
curInFlight atomic.Int64
|
||
}
|
||
|
||
func (s *fakeSchedulerFilerServer) CreateEntry(ctx context.Context, req *filer_pb.CreateEntryRequest) (*filer_pb.CreateEntryResponse, error) {
|
||
s.createCalls.Add(1)
|
||
cur := s.curInFlight.Add(1)
|
||
for {
|
||
peak := s.maxInFlight.Load()
|
||
if cur <= peak || s.maxInFlight.CompareAndSwap(peak, cur) {
|
||
break
|
||
}
|
||
}
|
||
if s.serviceDelay > 0 {
|
||
time.Sleep(s.serviceDelay)
|
||
}
|
||
s.curInFlight.Add(-1)
|
||
return &filer_pb.CreateEntryResponse{}, nil
|
||
}
|
||
|
||
func (s *fakeSchedulerFilerServer) StreamMutateEntry(stream grpc.BidiStreamingServer[filer_pb.StreamMutateEntryRequest, filer_pb.StreamMutateEntryResponse]) error {
|
||
var sendMu sync.Mutex
|
||
send := func(r *filer_pb.StreamMutateEntryResponse) error {
|
||
sendMu.Lock()
|
||
defer sendMu.Unlock()
|
||
return stream.Send(r)
|
||
}
|
||
sched := newMutateScheduler(s.concurrency)
|
||
var wg sync.WaitGroup
|
||
for {
|
||
req, err := stream.Recv()
|
||
if err == io.EOF {
|
||
wg.Wait()
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
wg.Wait()
|
||
return err
|
||
}
|
||
primary, secondary, kind := classifyMutation(req)
|
||
sched.Admit(primary, secondary, kind)
|
||
wg.Add(1)
|
||
go func(req *filer_pb.StreamMutateEntryRequest) {
|
||
defer wg.Done()
|
||
defer sched.Done(primary, secondary, kind)
|
||
switch r := req.Request.(type) {
|
||
case *filer_pb.StreamMutateEntryRequest_CreateRequest:
|
||
resp, _ := s.CreateEntry(stream.Context(), r.CreateRequest)
|
||
_ = send(&filer_pb.StreamMutateEntryResponse{
|
||
RequestId: req.RequestId,
|
||
IsLast: true,
|
||
Response: &filer_pb.StreamMutateEntryResponse_CreateResponse{CreateResponse: resp},
|
||
})
|
||
}
|
||
}(req)
|
||
}
|
||
}
|
||
|
||
func startFakeSchedulerFilerServer(t testing.TB, serviceDelay time.Duration, concurrency int) (string, *fakeSchedulerFilerServer, func()) {
|
||
t.Helper()
|
||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatalf("listen: %v", err)
|
||
}
|
||
srv := grpc.NewServer()
|
||
fake := &fakeSchedulerFilerServer{serviceDelay: serviceDelay, concurrency: concurrency}
|
||
filer_pb.RegisterSeaweedFilerServer(srv, fake)
|
||
go srv.Serve(lis)
|
||
return lis.Addr().String(), fake, func() {
|
||
srv.GracefulStop()
|
||
_ = lis.Close()
|
||
}
|
||
}
|
||
|
||
// startFakeFilerServer spins up an in-process gRPC filer with the given per-
|
||
// request service delay, returns the dial target and a shutdown func.
|
||
func startFakeFilerServer(t testing.TB, serviceDelay time.Duration) (string, *fakeFilerServer, func()) {
|
||
t.Helper()
|
||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
t.Fatalf("listen: %v", err)
|
||
}
|
||
srv := grpc.NewServer()
|
||
fake := &fakeFilerServer{serviceDelay: serviceDelay}
|
||
filer_pb.RegisterSeaweedFilerServer(srv, fake)
|
||
go srv.Serve(lis)
|
||
return lis.Addr().String(), fake, func() {
|
||
srv.GracefulStop()
|
||
_ = lis.Close()
|
||
}
|
||
}
|
||
|
||
// dialFakeFiler returns a client connection to the in-process filer.
|
||
func dialFakeFiler(t testing.TB, addr string) *grpc.ClientConn {
|
||
t.Helper()
|
||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||
if err != nil {
|
||
t.Fatalf("dial: %v", err)
|
||
}
|
||
return conn
|
||
}
|
||
|
||
// opsForWorker splits `ops` across `concurrency` workers, handing the
|
||
// remainder to the first `remainder` workers so the total is always exactly
|
||
// `ops`. Returns the count for worker index g.
|
||
func opsForWorker(g, concurrency, ops int) int {
|
||
per := ops / concurrency
|
||
if g < ops%concurrency {
|
||
return per + 1
|
||
}
|
||
return per
|
||
}
|
||
|
||
// runUnaryCreateWorkload fires `ops` CreateEntry unary RPCs across `concurrency`
|
||
// goroutines, all sharing a single client connection. Returns total duration.
|
||
func runUnaryCreateWorkload(t testing.TB, conn *grpc.ClientConn, concurrency, ops int) time.Duration {
|
||
t.Helper()
|
||
client := filer_pb.NewSeaweedFilerClient(conn)
|
||
var wg sync.WaitGroup
|
||
start := time.Now()
|
||
for g := 0; g < concurrency; g++ {
|
||
wg.Add(1)
|
||
count := opsForWorker(g, concurrency, ops)
|
||
go func() {
|
||
defer wg.Done()
|
||
for i := 0; i < count; i++ {
|
||
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||
Directory: "/",
|
||
Entry: &filer_pb.Entry{
|
||
Name: "f",
|
||
IsDirectory: false,
|
||
},
|
||
})
|
||
if err != nil {
|
||
t.Errorf("unary CreateEntry: %v", err)
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
return time.Since(start)
|
||
}
|
||
|
||
// pathFn chooses the (directory, name) of the i'th op. The default used by the
|
||
// existing callers is samePath — all ops hit "/f". distinctPath gives every
|
||
// op its own name so cross-path parallelism can kick in.
|
||
type pathFn func(i int) (dir, name string)
|
||
|
||
func samePath(_ int) (string, string) { return "/", "f" }
|
||
func distinctPath(i int) (string, string) { return "/", "f" + itoa(i) }
|
||
|
||
// itoa is a tight local decimal formatter — avoids a strconv import inside a
|
||
// hot benchmark loop.
|
||
func itoa(n int) string {
|
||
if n == 0 {
|
||
return "0"
|
||
}
|
||
buf := make([]byte, 0, 12)
|
||
for n > 0 {
|
||
buf = append(buf, byte('0'+n%10))
|
||
n /= 10
|
||
}
|
||
for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
|
||
buf[i], buf[j] = buf[j], buf[i]
|
||
}
|
||
return string(buf)
|
||
}
|
||
|
||
// runStreamCreateWorkload fires `ops` CreateEntry ops through a SINGLE bidi
|
||
// stream, with `concurrency` goroutines all submitting to the same stream via
|
||
// a small multiplexer that mirrors weed/mount/weedfs_stream_mutate.go's
|
||
// sendLoop+recvLoop structure. Returns total duration.
|
||
func runStreamCreateWorkload(t testing.TB, conn *grpc.ClientConn, concurrency, ops int) time.Duration {
|
||
return runStreamCreateWorkloadAt(t, conn, concurrency, ops, samePath)
|
||
}
|
||
|
||
func runStreamCreateWorkloadAt(t testing.TB, conn *grpc.ClientConn, concurrency, ops int, path pathFn) time.Duration {
|
||
t.Helper()
|
||
client := filer_pb.NewSeaweedFilerClient(conn)
|
||
stream, err := client.StreamMutateEntry(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("open stream: %v", err)
|
||
}
|
||
|
||
var nextID atomic.Uint64
|
||
var pending sync.Map // map[uint64]chan struct{}
|
||
|
||
// single recv goroutine, dispatching by request id
|
||
recvErr := make(chan error, 1)
|
||
go func() {
|
||
for {
|
||
resp, err := stream.Recv()
|
||
if err != nil {
|
||
recvErr <- err
|
||
return
|
||
}
|
||
if ch, ok := pending.LoadAndDelete(resp.RequestId); ok {
|
||
close(ch.(chan struct{}))
|
||
}
|
||
}
|
||
}()
|
||
|
||
// dedicated send goroutine (gRPC Send is not concurrent-safe)
|
||
sendCh := make(chan *filer_pb.StreamMutateEntryRequest, 512)
|
||
sendErr := make(chan error, 1)
|
||
go func() {
|
||
for req := range sendCh {
|
||
if err := stream.Send(req); err != nil {
|
||
sendErr <- err
|
||
return
|
||
}
|
||
}
|
||
sendErr <- nil
|
||
}()
|
||
|
||
var wg sync.WaitGroup
|
||
start := time.Now()
|
||
offset := 0
|
||
for g := 0; g < concurrency; g++ {
|
||
wg.Add(1)
|
||
count := opsForWorker(g, concurrency, ops)
|
||
startOff := offset
|
||
offset += count
|
||
go func(startOff, count int) {
|
||
defer wg.Done()
|
||
for i := 0; i < count; i++ {
|
||
id := nextID.Add(1)
|
||
done := make(chan struct{})
|
||
pending.Store(id, done)
|
||
dir, name := path(startOff + i)
|
||
sendCh <- &filer_pb.StreamMutateEntryRequest{
|
||
RequestId: id,
|
||
Request: &filer_pb.StreamMutateEntryRequest_CreateRequest{
|
||
CreateRequest: &filer_pb.CreateEntryRequest{
|
||
Directory: dir,
|
||
Entry: &filer_pb.Entry{Name: name},
|
||
},
|
||
},
|
||
}
|
||
<-done
|
||
}
|
||
}(startOff, count)
|
||
}
|
||
wg.Wait()
|
||
elapsed := time.Since(start)
|
||
|
||
close(sendCh)
|
||
_ = stream.CloseSend()
|
||
select {
|
||
case <-sendErr:
|
||
case <-time.After(time.Second):
|
||
}
|
||
select {
|
||
case <-recvErr:
|
||
case <-time.After(time.Second):
|
||
}
|
||
return elapsed
|
||
}
|
||
|
||
// runStreamAsyncCreateWorkload fires `ops` CreateEntry ops through a single
|
||
// bidi stream WITHOUT the client waiting for responses between sends. The
|
||
// timer starts at the first send and stops when the last response is received,
|
||
// so this measures true end-to-end throughput (not just client-send rate).
|
||
//
|
||
// This isolates the question: does relaxing client-side per-request waiting
|
||
// help throughput when the server is a serial loop?
|
||
func runStreamAsyncCreateWorkload(t testing.TB, conn *grpc.ClientConn, concurrency, ops int) time.Duration {
|
||
t.Helper()
|
||
client := filer_pb.NewSeaweedFilerClient(conn)
|
||
stream, err := client.StreamMutateEntry(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("open stream: %v", err)
|
||
}
|
||
|
||
var nextID atomic.Uint64
|
||
var outstanding atomic.Int64
|
||
allDone := make(chan struct{})
|
||
|
||
// Single recv goroutine — signals allDone when the last response arrives.
|
||
recvErr := make(chan error, 1)
|
||
go func() {
|
||
for {
|
||
_, err := stream.Recv()
|
||
if err != nil {
|
||
recvErr <- err
|
||
return
|
||
}
|
||
if outstanding.Add(-1) == 0 {
|
||
select {
|
||
case <-allDone:
|
||
default:
|
||
close(allDone)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
|
||
// Dedicated send goroutine (gRPC Send is not concurrent-safe).
|
||
sendCh := make(chan *filer_pb.StreamMutateEntryRequest, 4096)
|
||
sendErr := make(chan error, 1)
|
||
go func() {
|
||
for req := range sendCh {
|
||
if err := stream.Send(req); err != nil {
|
||
sendErr <- err
|
||
return
|
||
}
|
||
}
|
||
sendErr <- nil
|
||
}()
|
||
|
||
outstanding.Store(int64(ops))
|
||
|
||
var wg sync.WaitGroup
|
||
start := time.Now()
|
||
for g := 0; g < concurrency; g++ {
|
||
wg.Add(1)
|
||
count := opsForWorker(g, concurrency, ops)
|
||
go func(count int) {
|
||
defer wg.Done()
|
||
for i := 0; i < count; i++ {
|
||
id := nextID.Add(1)
|
||
// Fire-and-forget: push to sendCh, do not wait for response.
|
||
sendCh <- &filer_pb.StreamMutateEntryRequest{
|
||
RequestId: id,
|
||
Request: &filer_pb.StreamMutateEntryRequest_CreateRequest{
|
||
CreateRequest: &filer_pb.CreateEntryRequest{
|
||
Directory: "/",
|
||
Entry: &filer_pb.Entry{Name: "f"},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
}(count)
|
||
}
|
||
wg.Wait()
|
||
// All requests queued; wait for server to drain all responses.
|
||
<-allDone
|
||
elapsed := time.Since(start)
|
||
|
||
close(sendCh)
|
||
_ = stream.CloseSend()
|
||
select {
|
||
case <-sendErr:
|
||
case <-time.After(time.Second):
|
||
}
|
||
select {
|
||
case <-recvErr:
|
||
case <-time.After(time.Second):
|
||
}
|
||
return elapsed
|
||
}
|
||
|
||
// TestReproStreamSerializationCeiling runs a head-to-head: same concurrency,
|
||
// same op count, same per-request service delay. The only difference is the
|
||
// transport — unary (one goroutine per RPC at the server) vs single bidi stream
|
||
// (one goroutine for the whole stream at the server).
|
||
//
|
||
// With a 2ms per-request service delay, unary with N=12 should deliver ~6000/s
|
||
// (12 concurrent handlers, each 2ms), while stream delivers ~500/s (serial).
|
||
func TestReproStreamSerializationCeiling(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping repro in -short mode")
|
||
}
|
||
|
||
const (
|
||
concurrency = 12
|
||
opsPerRun = 1200
|
||
serviceDelay = 2 * time.Millisecond
|
||
)
|
||
|
||
addr, fake, stop := startFakeFilerServer(t, serviceDelay)
|
||
defer stop()
|
||
|
||
conn := dialFakeFiler(t, addr)
|
||
defer conn.Close()
|
||
|
||
// warm up one call so the gRPC HTTP/2 connection is fully established
|
||
warmupClient := filer_pb.NewSeaweedFilerClient(conn)
|
||
if _, err := warmupClient.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||
Directory: "/",
|
||
Entry: &filer_pb.Entry{Name: "warmup"},
|
||
}); err != nil {
|
||
t.Fatalf("warmup: %v", err)
|
||
}
|
||
fake.createCalls.Store(0)
|
||
|
||
unaryDur := runUnaryCreateWorkload(t, conn, concurrency, opsPerRun)
|
||
unaryCalls := fake.createCalls.Load()
|
||
fake.createCalls.Store(0)
|
||
|
||
streamDur := runStreamCreateWorkload(t, conn, concurrency, opsPerRun)
|
||
streamCalls := fake.createCalls.Load()
|
||
|
||
unaryQPS := float64(unaryCalls) / unaryDur.Seconds()
|
||
streamQPS := float64(streamCalls) / streamDur.Seconds()
|
||
|
||
t.Logf("service delay: %v", serviceDelay)
|
||
t.Logf("concurrency: %d", concurrency)
|
||
t.Logf("ops per run: %d", opsPerRun)
|
||
t.Logf("unary : %d calls in %v -> %.0f QPS", unaryCalls, unaryDur, unaryQPS)
|
||
t.Logf("stream : %d calls in %v -> %.0f QPS", streamCalls, streamDur, streamQPS)
|
||
t.Logf("unary / stream ratio: %.2fx", unaryQPS/streamQPS)
|
||
|
||
// The serial stream handler cannot exceed 1 / serviceDelay regardless of
|
||
// client concurrency. Assert that unary is meaningfully faster — if the
|
||
// server is ever parallelized, this assertion is safe to revisit.
|
||
if unaryQPS < streamQPS*2 {
|
||
t.Fatalf("expected unary to be at least 2x faster than stream, got unary=%.0f stream=%.0f",
|
||
unaryQPS, streamQPS)
|
||
}
|
||
|
||
// Stream QPS should cluster near the theoretical ceiling of 1/serviceDelay.
|
||
theoreticalMax := 1.0 / serviceDelay.Seconds()
|
||
if streamQPS > theoreticalMax*1.25 {
|
||
t.Fatalf("stream QPS %.0f exceeds theoretical serial ceiling %.0f — handler may have changed",
|
||
streamQPS, theoreticalMax)
|
||
}
|
||
}
|
||
|
||
// TestServerSerialVsConcurrentHandler measures the lift from changing the
|
||
// server-side handler from serial (one goroutine per stream) to concurrent
|
||
// (per-request goroutine, bounded by a semaphore, Send protected by a mutex).
|
||
//
|
||
// Client is identical in both runs (sync stream, N concurrent submitters).
|
||
// Only the server handler differs.
|
||
func TestServerSerialVsConcurrentHandler(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping repro in -short mode")
|
||
}
|
||
|
||
const (
|
||
opsPerRun = 2400
|
||
serviceDelay = 2 * time.Millisecond
|
||
serverConcurrency = 64
|
||
)
|
||
|
||
// Serial server (the OLD handler).
|
||
serialAddr, serialFake, stopSerial := startFakeFilerServer(t, serviceDelay)
|
||
defer stopSerial()
|
||
serialConn := dialFakeFiler(t, serialAddr)
|
||
defer serialConn.Close()
|
||
|
||
// Concurrent server (the NEW handler).
|
||
concAddr, concFake, stopConc := startFakeConcurrentFilerServer(t, serviceDelay, serverConcurrency)
|
||
defer stopConc()
|
||
concConn := dialFakeFiler(t, concAddr)
|
||
defer concConn.Close()
|
||
|
||
// Warm up both connections.
|
||
for _, c := range []*grpc.ClientConn{serialConn, concConn} {
|
||
client := filer_pb.NewSeaweedFilerClient(c)
|
||
if _, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||
Directory: "/",
|
||
Entry: &filer_pb.Entry{Name: "warmup"},
|
||
}); err != nil {
|
||
t.Fatalf("warmup: %v", err)
|
||
}
|
||
}
|
||
|
||
clientConcurrencies := []int{1, 12, 64, 256}
|
||
theoreticalSerial := 1.0 / serviceDelay.Seconds()
|
||
theoreticalConcurrent := float64(serverConcurrency) / serviceDelay.Seconds()
|
||
|
||
t.Logf("service delay: %v", serviceDelay)
|
||
t.Logf("server-side concurrency cap: %d", serverConcurrency)
|
||
t.Logf("theoretical serial ceiling: %.0f QPS", theoreticalSerial)
|
||
t.Logf("theoretical concurrent cap: %.0f QPS", theoreticalConcurrent)
|
||
t.Logf("ops per run: %d", opsPerRun)
|
||
t.Logf("")
|
||
t.Logf("%-16s %-14s %-14s %-10s",
|
||
"client workers", "serial QPS", "concurrent QPS", "lift")
|
||
|
||
for _, c := range clientConcurrencies {
|
||
serialFake.createCalls.Store(0)
|
||
serialDur := runStreamCreateWorkload(t, serialConn, c, opsPerRun)
|
||
serialCalls := serialFake.createCalls.Load()
|
||
serialQPS := float64(serialCalls) / serialDur.Seconds()
|
||
|
||
concFake.createCalls.Store(0)
|
||
concDur := runStreamCreateWorkload(t, concConn, c, opsPerRun)
|
||
concCalls := concFake.createCalls.Load()
|
||
concQPS := float64(concCalls) / concDur.Seconds()
|
||
|
||
t.Logf("%-16d %-14.0f %-14.0f %.2fx",
|
||
c, serialQPS, concQPS, concQPS/serialQPS)
|
||
}
|
||
}
|
||
|
||
// TestSchedulerOrderedParallelism compares three server-side handler shapes
|
||
// under two workloads:
|
||
//
|
||
// serial : OLD handler — one goroutine per stream
|
||
// sem : NEW handler v1 — per-request goroutine + semaphore, no ordering
|
||
// scheduler : NEW handler v2 — per-path admission + subtree barriers
|
||
// (mirrors filer.sync's MetadataProcessor)
|
||
//
|
||
// Workload "distinct" puts each op on its own path; workload "same" puts every
|
||
// op on the same path.
|
||
//
|
||
// Expected shape:
|
||
// - serial — both workloads ~1/serviceDelay (no parallelism)
|
||
// - sem — both workloads lift ~serverConcurrency× (wrong: reorders
|
||
// same-path ops, acceptable for our filer store but undesired)
|
||
// - scheduler — distinct workload lifts ~serverConcurrency×
|
||
// same workload falls back to ~1/serviceDelay (correctness)
|
||
func TestSchedulerOrderedParallelism(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping repro in -short mode")
|
||
}
|
||
|
||
const (
|
||
opsPerRun = 2400
|
||
serviceDelay = 2 * time.Millisecond
|
||
serverConcurrency = 64
|
||
clientWorkers = 12
|
||
)
|
||
|
||
// Serial, sem-only concurrent, scheduler-based concurrent.
|
||
serialAddr, serialFake, stopSerial := startFakeFilerServer(t, serviceDelay)
|
||
defer stopSerial()
|
||
serialConn := dialFakeFiler(t, serialAddr)
|
||
defer serialConn.Close()
|
||
|
||
semAddr, semFake, stopSem := startFakeConcurrentFilerServer(t, serviceDelay, serverConcurrency)
|
||
defer stopSem()
|
||
semConn := dialFakeFiler(t, semAddr)
|
||
defer semConn.Close()
|
||
|
||
schedAddr, schedFake, stopSched := startFakeSchedulerFilerServer(t, serviceDelay, serverConcurrency)
|
||
defer stopSched()
|
||
schedConn := dialFakeFiler(t, schedAddr)
|
||
defer schedConn.Close()
|
||
|
||
// Warm up all three connections.
|
||
for _, c := range []*grpc.ClientConn{serialConn, semConn, schedConn} {
|
||
client := filer_pb.NewSeaweedFilerClient(c)
|
||
if _, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||
Directory: "/",
|
||
Entry: &filer_pb.Entry{Name: "warmup"},
|
||
}); err != nil {
|
||
t.Fatalf("warmup: %v", err)
|
||
}
|
||
}
|
||
|
||
t.Logf("service delay: %v", serviceDelay)
|
||
t.Logf("server concurrency: %d", serverConcurrency)
|
||
t.Logf("client workers: %d", clientWorkers)
|
||
t.Logf("ops per run: %d", opsPerRun)
|
||
t.Logf("serial ceiling: %.0f QPS", 1.0/serviceDelay.Seconds())
|
||
t.Logf("concurrent ceiling: %.0f QPS (%d x %.0f)", float64(serverConcurrency)/serviceDelay.Seconds(), serverConcurrency, 1.0/serviceDelay.Seconds())
|
||
t.Logf("")
|
||
|
||
workloads := []struct {
|
||
name string
|
||
fn pathFn
|
||
}{
|
||
{"distinct paths", distinctPath},
|
||
{"same path ", samePath},
|
||
}
|
||
|
||
t.Logf("%-16s %-14s %-14s %-14s %-14s",
|
||
"workload", "serial QPS", "sem QPS", "sched QPS", "sched peak-conc")
|
||
|
||
for _, w := range workloads {
|
||
serialFake.createCalls.Store(0)
|
||
serialDur := runStreamCreateWorkloadAt(t, serialConn, clientWorkers, opsPerRun, w.fn)
|
||
serialQPS := float64(serialFake.createCalls.Load()) / serialDur.Seconds()
|
||
|
||
semFake.createCalls.Store(0)
|
||
semDur := runStreamCreateWorkloadAt(t, semConn, clientWorkers, opsPerRun, w.fn)
|
||
semQPS := float64(semFake.createCalls.Load()) / semDur.Seconds()
|
||
|
||
schedFake.createCalls.Store(0)
|
||
schedFake.maxInFlight.Store(0)
|
||
schedDur := runStreamCreateWorkloadAt(t, schedConn, clientWorkers, opsPerRun, w.fn)
|
||
schedQPS := float64(schedFake.createCalls.Load()) / schedDur.Seconds()
|
||
schedPeak := schedFake.maxInFlight.Load()
|
||
|
||
t.Logf("%-16s %-14.0f %-14.0f %-14.0f %d",
|
||
w.name, serialQPS, semQPS, schedQPS, schedPeak)
|
||
}
|
||
}
|
||
|
||
// TestStreamSyncVsAsyncClient measures whether making the CLIENT side
|
||
// asynchronous (fire-and-forget, no per-request waits) lifts the server-set
|
||
// ceiling. It runs sync and async workloads against the same serial server
|
||
// and compares end-to-end throughput.
|
||
//
|
||
// Expectation: at steady state, async cannot exceed 1/serviceDelay because the
|
||
// server still processes one request at a time. Any "improvement" is transient
|
||
// (in-flight pipelining up to the HTTP/2 window) and disappears as ops >> window.
|
||
func TestStreamSyncVsAsyncClient(t *testing.T) {
|
||
if testing.Short() {
|
||
t.Skip("skipping repro in -short mode")
|
||
}
|
||
|
||
const (
|
||
opsPerRun = 2400
|
||
serviceDelay = 2 * time.Millisecond
|
||
)
|
||
|
||
addr, fake, stop := startFakeFilerServer(t, serviceDelay)
|
||
defer stop()
|
||
|
||
conn := dialFakeFiler(t, addr)
|
||
defer conn.Close()
|
||
|
||
warmupClient := filer_pb.NewSeaweedFilerClient(conn)
|
||
if _, err := warmupClient.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
||
Directory: "/",
|
||
Entry: &filer_pb.Entry{Name: "warmup"},
|
||
}); err != nil {
|
||
t.Fatalf("warmup: %v", err)
|
||
}
|
||
|
||
// Sweep concurrency to show that async does not break the 1/serviceDelay
|
||
// ceiling no matter how much client-side parallelism we throw at it.
|
||
concurrencies := []int{1, 12, 64, 256}
|
||
t.Logf("service delay: %v (theoretical serial ceiling = %.0f QPS)",
|
||
serviceDelay, 1.0/serviceDelay.Seconds())
|
||
t.Logf("ops per run: %d", opsPerRun)
|
||
t.Logf("")
|
||
t.Logf("%-12s %-12s %-12s %-10s", "concurrency", "sync QPS", "async QPS", "ratio")
|
||
|
||
for _, c := range concurrencies {
|
||
fake.createCalls.Store(0)
|
||
syncDur := runStreamCreateWorkload(t, conn, c, opsPerRun)
|
||
syncCalls := fake.createCalls.Load()
|
||
syncQPS := float64(syncCalls) / syncDur.Seconds()
|
||
|
||
fake.createCalls.Store(0)
|
||
asyncDur := runStreamAsyncCreateWorkload(t, conn, c, opsPerRun)
|
||
asyncCalls := fake.createCalls.Load()
|
||
asyncQPS := float64(asyncCalls) / asyncDur.Seconds()
|
||
|
||
t.Logf("%-12d %-12.0f %-12.0f %-10.2fx",
|
||
c, syncQPS, asyncQPS, asyncQPS/syncQPS)
|
||
}
|
||
}
|