Files
upstream-seaweedfs/weed/server/filer_grpc_server_stream_mutate_bench_test.go
upstream-mirror 89f5be005d
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
snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 03:23:42 +05:00

793 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}