- Add gateway real path implementation (06_gateway_real_path) - Add gateway cleanup tasks (07+06_gateway_cleanup) - Update Go WebSocket client and benchmark - Update Kotlin Communicator, TcpClient, WsClient and stress tests - Update TypeScript base_client, communicator, inbound_gateway, node variants, tcp/ws clients and tests - Update Kotlin and TypeScript stress benchmarks - Update roadmap milestone documentation
841 lines
25 KiB
Go
841 lines
25 KiB
Go
//go:build ignore
|
|
|
|
// Same-language Go stress / benchmark harness.
|
|
//
|
|
// 고성능 병렬 운용 기준선 Milestone의 lang-baseline Task에서, TypeScript stress.ts와
|
|
// 같은 출력 계약(ROW|/SKIP|/SUMMARY|)으로 Go same-language TCP/WS baseline을 남긴다.
|
|
// 절대 성능 합격선은 고정하지 않고 local 환경 baseline(throughput, p50/p95/p99 latency,
|
|
// peak heap)을 기록하며, 안정성 합격선만 hard fail로 둔다: timeout 0, nonce mismatch 0,
|
|
// response type mismatch 0, connection별 FIFO 위반 0, 종료 후 pending leak 0.
|
|
//
|
|
// 실행: go run ./bench/stress.go [--mode=quick|full] [--transport=tcp,ws] [--profiles=a,b]
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
"nhooyr.io/websocket"
|
|
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"git.toki-labs.com/toki/proto-socket/go/packets"
|
|
)
|
|
|
|
const (
|
|
host = "127.0.0.1"
|
|
language = "Go"
|
|
wsPath = "/"
|
|
transTCP = "tcp"
|
|
transWS = "ws"
|
|
)
|
|
|
|
var allProfiles = []string{"roundtrip", "burst", "sustained", "parallel", "slow-mix", "payload"}
|
|
|
|
// payload size matrix 축. quick은 작은 샘플(1KB/64KB), full은 Milestone 기준(1KB/64KB/1MB)을 측정한다.
|
|
const payloadSeed = "0123456789abcdef"
|
|
|
|
var (
|
|
payloadSizesQuick = []int{1024, 65536}
|
|
payloadSizesFull = []int{1024, 65536, 1048576}
|
|
)
|
|
|
|
// stability는 안정성 위반 카운터다. 0이 아니면 해당 row는 FAIL이고 프로세스도 non-zero exit한다.
|
|
type stability struct {
|
|
timeouts atomic.Int64
|
|
nonceMismatch atomic.Int64
|
|
typeMismatch atomic.Int64
|
|
fifoViolations atomic.Int64
|
|
pendingLeak atomic.Int64
|
|
}
|
|
|
|
func (s *stability) violations() int64 {
|
|
return s.timeouts.Load() + s.nonceMismatch.Load() + s.typeMismatch.Load() +
|
|
s.fifoViolations.Load() + s.pendingLeak.Load()
|
|
}
|
|
|
|
type clientHandle struct {
|
|
comm *toki.Communicator
|
|
send func(proto.Message) error
|
|
close func() error
|
|
}
|
|
|
|
type serverHandle struct {
|
|
port int
|
|
stop func()
|
|
}
|
|
|
|
var (
|
|
rowsEmitted int
|
|
totalViol int64
|
|
)
|
|
|
|
func parserMap() toki.ParserMap {
|
|
return toki.ParserMap{
|
|
toki.TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) {
|
|
m := &packets.TestData{}
|
|
return m, proto.Unmarshal(b, m)
|
|
},
|
|
}
|
|
}
|
|
|
|
func logf(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
|
}
|
|
|
|
func memMb() float64 {
|
|
var m runtime.MemStats
|
|
runtime.ReadMemStats(&m)
|
|
return float64(m.HeapAlloc) / (1024 * 1024)
|
|
}
|
|
|
|
func freePort() (int, error) {
|
|
l, err := net.Listen("tcp", host+":0")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
_ = l.Close()
|
|
return port, nil
|
|
}
|
|
|
|
func testDataPayloadBytes(message string) int {
|
|
b, err := proto.Marshal(&packets.TestData{Index: 0, Message: message})
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return len(b)
|
|
}
|
|
|
|
// payloadFiller는 size 바이트 길이의 deterministic filler string을 만든다.
|
|
func payloadFiller(size int) string {
|
|
if size <= 0 {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(size + len(payloadSeed))
|
|
for b.Len() < size {
|
|
b.WriteString(payloadSeed)
|
|
}
|
|
return b.String()[:size]
|
|
}
|
|
|
|
// payloadLabel은 payload 크기를 사람이 읽는 축 이름으로 바꾼다(1024 -> 1KB, 1048576 -> 1MB).
|
|
func payloadLabel(bytes int) string {
|
|
switch {
|
|
case bytes >= 1048576 && bytes%1048576 == 0:
|
|
return fmt.Sprintf("%dMB", bytes/1048576)
|
|
case bytes >= 1024 && bytes%1024 == 0:
|
|
return fmt.Sprintf("%dKB", bytes/1024)
|
|
default:
|
|
return fmt.Sprintf("%dB", bytes)
|
|
}
|
|
}
|
|
|
|
func percentile(sorted []float64, p float64) float64 {
|
|
if len(sorted) == 0 {
|
|
return 0
|
|
}
|
|
rank := int((p/100.0)*float64(len(sorted))+0.999999) - 1
|
|
if rank < 0 {
|
|
rank = 0
|
|
}
|
|
if rank > len(sorted)-1 {
|
|
rank = len(sorted) - 1
|
|
}
|
|
return sorted[rank]
|
|
}
|
|
|
|
type rowInput struct {
|
|
profile string
|
|
axis string
|
|
transport string
|
|
payloadBytes int
|
|
clientCount int
|
|
requests int
|
|
throughput float64
|
|
p50, p95, p99 float64
|
|
memMb float64
|
|
stab *stability
|
|
}
|
|
|
|
func emitRow(r rowInput) {
|
|
viol := r.stab.violations()
|
|
status := "PASS"
|
|
if viol != 0 {
|
|
status = "FAIL"
|
|
}
|
|
totalViol += viol
|
|
rowsEmitted++
|
|
fields := []string{
|
|
"ROW",
|
|
r.profile,
|
|
r.axis,
|
|
language,
|
|
r.transport,
|
|
fmt.Sprintf("%d", r.payloadBytes),
|
|
fmt.Sprintf("%d", r.clientCount),
|
|
fmt.Sprintf("%d", r.requests),
|
|
fmt.Sprintf("%.1f", r.throughput),
|
|
fmt.Sprintf("%.3f", r.p50),
|
|
fmt.Sprintf("%.3f", r.p95),
|
|
fmt.Sprintf("%.3f", r.p99),
|
|
fmt.Sprintf("%d", r.stab.timeouts.Load()),
|
|
fmt.Sprintf("%d", r.stab.nonceMismatch.Load()),
|
|
fmt.Sprintf("%d", r.stab.typeMismatch.Load()),
|
|
fmt.Sprintf("%d", r.stab.fifoViolations.Load()),
|
|
fmt.Sprintf("%d", r.stab.pendingLeak.Load()),
|
|
"0", // queueBacklog: same-language baseline에는 inbound gateway가 없다.
|
|
"0", // gatewayBacklog
|
|
"off",
|
|
fmt.Sprintf("%.1f", r.memMb),
|
|
status,
|
|
}
|
|
fmt.Println(strings.Join(fields, "|"))
|
|
}
|
|
|
|
func emitSkip(profile, axis, transport, reason string) {
|
|
fmt.Printf("SKIP|%s|%s|%s|%s|%s\n", profile, axis, language, transport, reason)
|
|
}
|
|
|
|
func classifyRequestError(err error, s *stability) {
|
|
msg := err.Error()
|
|
switch {
|
|
case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"):
|
|
s.timeouts.Add(1)
|
|
case strings.Contains(msg, "type mismatch"):
|
|
s.typeMismatch.Add(1)
|
|
default:
|
|
s.nonceMismatch.Add(1)
|
|
}
|
|
}
|
|
|
|
// startRequestServer는 request-response echo 서버를 띄운다.
|
|
//
|
|
// request-response 축에서는 client가 concurrency C로 동시에 send하므로 server의 frame 도착 순서가
|
|
// 결정적이지 않다. 따라서 여기서 req.index 단조 증가를 FIFO 위반으로 보면 안 된다. per-connection
|
|
// FIFO는 단일 connection에서 순차 send하는 burst 축에서 검증한다. 여기서는 응답 정확성(nonce/type
|
|
// matching)과 timeout만 안정성 합격선으로 둔다.
|
|
func startRequestServer(ctx context.Context, transport string, s *stability) (*serverHandle, error) {
|
|
port, err := freePort()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
register := func(comm *toki.Communicator) {
|
|
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](comm, func(req *packets.TestData) (*packets.TestData, error) {
|
|
return &packets.TestData{Index: req.GetIndex() * 2, Message: "echo:" + req.GetMessage()}, nil
|
|
})
|
|
}
|
|
return startServer(ctx, transport, port, register)
|
|
}
|
|
|
|
func startServer(ctx context.Context, transport string, port int, onConnected func(*toki.Communicator)) (*serverHandle, error) {
|
|
switch transport {
|
|
case transTCP:
|
|
srv := toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
|
|
return toki.NewTcpClient(conn, 0, 0, parserMap())
|
|
})
|
|
srv.OnClientConnected = func(client *toki.TcpClient) { onConnected(&client.Communicator) }
|
|
if err := srv.Start(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return &serverHandle{port: port, stop: func() { _ = srv.Stop() }}, nil
|
|
case transWS:
|
|
srv := toki.NewWsServer(host, port, wsPath, func(conn *websocket.Conn) *toki.WsClient {
|
|
return toki.NewWsClient(conn, 0, 0, parserMap())
|
|
})
|
|
srv.OnClientConnected = func(client *toki.WsClient) { onConnected(&client.Communicator) }
|
|
if err := srv.Start(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
return &serverHandle{port: port, stop: func() { _ = srv.Stop() }}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown transport %q", transport)
|
|
}
|
|
}
|
|
|
|
func dial(ctx context.Context, transport string, port int) (*clientHandle, error) {
|
|
switch transport {
|
|
case transTCP:
|
|
c, err := toki.DialTcp(ctx, host, port, 0, 0, parserMap())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &clientHandle{comm: &c.Communicator, send: c.Send, close: c.Close}, nil
|
|
case transWS:
|
|
c, err := toki.DialWsWithHeartbeat(ctx, host, port, wsPath, 0, 0, parserMap())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &clientHandle{comm: &c.Communicator, send: c.Send, close: c.Close}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown transport %q", transport)
|
|
}
|
|
}
|
|
|
|
func dialWithRetry(transport string, port int) (*clientHandle, error) {
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
var lastErr error
|
|
for time.Now().Before(deadline) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
|
|
h, err := dial(ctx, transport, port)
|
|
cancel()
|
|
if err == nil {
|
|
return h, nil
|
|
}
|
|
lastErr = err
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
return nil, fmt.Errorf("connect %s:%d timed out: %w", transport, port, lastErr)
|
|
}
|
|
|
|
// runRequestLoad는 한 connection에서 concurrency C로 request batch를 보내고 latency를 모은다.
|
|
func runRequestLoad(h *clientHandle, total, concurrency int, timeout time.Duration, s *stability) []float64 {
|
|
var mu sync.Mutex
|
|
latencies := make([]float64, 0, total)
|
|
issued := 0
|
|
nextIndex := int32(0)
|
|
for issued < total {
|
|
batchSize := concurrency
|
|
if total-issued < batchSize {
|
|
batchSize = total - issued
|
|
}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < batchSize; i++ {
|
|
idx := nextIndex
|
|
nextIndex++
|
|
wg.Add(1)
|
|
go func(index int32) {
|
|
defer wg.Done()
|
|
started := time.Now()
|
|
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
|
|
h.comm,
|
|
&packets.TestData{Index: index, Message: fmt.Sprintf("req-%d", index)},
|
|
timeout,
|
|
)
|
|
if err != nil {
|
|
classifyRequestError(err, s)
|
|
return
|
|
}
|
|
elapsed := float64(time.Since(started).Microseconds()) / 1000.0
|
|
mu.Lock()
|
|
latencies = append(latencies, elapsed)
|
|
mu.Unlock()
|
|
if res.GetIndex() != index*2 || res.GetMessage() != fmt.Sprintf("echo:req-%d", index) {
|
|
s.nonceMismatch.Add(1)
|
|
}
|
|
}(idx)
|
|
}
|
|
issued += batchSize
|
|
wg.Wait()
|
|
}
|
|
return latencies
|
|
}
|
|
|
|
func summarize(profile, axis, transport string, latencies []float64, elapsedMs float64, clientCount int, s *stability, observedMem float64) {
|
|
sorted := append([]float64(nil), latencies...)
|
|
sort.Float64s(sorted)
|
|
throughput := 0.0
|
|
if elapsedMs > 0 {
|
|
throughput = float64(len(latencies)) / elapsedMs * 1000.0
|
|
}
|
|
emitRow(rowInput{
|
|
profile: profile,
|
|
axis: axis,
|
|
transport: transport,
|
|
payloadBytes: testDataPayloadBytes("req-0"),
|
|
clientCount: clientCount,
|
|
requests: len(latencies),
|
|
throughput: throughput,
|
|
p50: percentile(sorted, 50),
|
|
p95: percentile(sorted, 95),
|
|
p99: percentile(sorted, 99),
|
|
memMb: observedMem,
|
|
stab: s,
|
|
})
|
|
}
|
|
|
|
func profileRoundtrip(transport string, mode string) {
|
|
concurrencies := []int{1, 16, 64, 256}
|
|
batches := 2
|
|
timeout := 5 * time.Second
|
|
if mode == "full" {
|
|
batches = 20
|
|
timeout = 15 * time.Second
|
|
}
|
|
logf("[roundtrip] transport=%s mode=%s concurrencies=1,16,64,256 batches/level=%d", transport, mode, batches)
|
|
for _, c := range concurrencies {
|
|
s := &stability{}
|
|
total := c * batches
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
srv, err := startRequestServer(ctx, transport, s)
|
|
if err != nil {
|
|
cancel()
|
|
s.timeouts.Add(1)
|
|
logf("[roundtrip] c=%d server error=%v", c, err)
|
|
summarize("roundtrip", fmt.Sprintf("concurrency=%d", c), transport, nil, 0, 1, s, memMb())
|
|
continue
|
|
}
|
|
h, err := dialWithRetry(transport, srv.port)
|
|
if err != nil {
|
|
s.timeouts.Add(1)
|
|
logf("[roundtrip] c=%d dial error=%v", c, err)
|
|
} else {
|
|
started := time.Now()
|
|
latencies := runRequestLoad(h, total, c, timeout, s)
|
|
elapsed := float64(time.Since(started).Microseconds()) / 1000.0
|
|
summarize("roundtrip", fmt.Sprintf("concurrency=%d", c), transport, latencies, elapsed, 1, s, memMb())
|
|
_ = h.close()
|
|
if h.comm.IsAlive() {
|
|
s.pendingLeak.Add(1)
|
|
}
|
|
}
|
|
srv.stop()
|
|
cancel()
|
|
logf("[roundtrip] transport=%s concurrency=%d done violations=%d", transport, c, s.violations())
|
|
}
|
|
}
|
|
|
|
func profileBurst(transport string, mode string) {
|
|
counts := []int{200}
|
|
if mode == "full" {
|
|
counts = []int{1000, 10000, 100000}
|
|
}
|
|
logf("[burst] transport=%s mode=%s counts=%v", transport, mode, counts)
|
|
for _, count := range counts {
|
|
s := &stability{}
|
|
var received atomic.Int64
|
|
var lastIndex atomic.Int64
|
|
lastIndex.Store(-1)
|
|
done := make(chan struct{})
|
|
var doneOnce sync.Once
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
srv, err := startServer(ctx, transport, mustPort(), func(comm *toki.Communicator) {
|
|
toki.AddListenerTyped[*packets.TestData](comm, func(msg *packets.TestData) {
|
|
if int64(msg.GetIndex()) <= lastIndex.Load() {
|
|
s.fifoViolations.Add(1)
|
|
}
|
|
lastIndex.Store(int64(msg.GetIndex()))
|
|
if received.Add(1) >= int64(count) {
|
|
doneOnce.Do(func() { close(done) })
|
|
}
|
|
})
|
|
})
|
|
if err != nil {
|
|
cancel()
|
|
s.timeouts.Add(1)
|
|
logf("[burst] count=%d server error=%v", count, err)
|
|
emitThroughput("burst", fmt.Sprintf("count=%d", count), transport, 0, 0, 1, testDataPayloadBytes("b-0"), s, memMb())
|
|
continue
|
|
}
|
|
h, err := dialWithRetry(transport, srv.port)
|
|
if err != nil {
|
|
s.timeouts.Add(1)
|
|
srv.stop()
|
|
cancel()
|
|
emitThroughput("burst", fmt.Sprintf("count=%d", count), transport, 0, 0, 1, testDataPayloadBytes("b-0"), s, memMb())
|
|
continue
|
|
}
|
|
started := time.Now()
|
|
for i := 0; i < count; i++ {
|
|
if err := h.send(&packets.TestData{Index: int32(i), Message: fmt.Sprintf("b-%d", i)}); err != nil {
|
|
s.timeouts.Add(1)
|
|
break
|
|
}
|
|
}
|
|
timeout := 10 * time.Second
|
|
if mode == "full" {
|
|
timeout = 60 * time.Second
|
|
}
|
|
select {
|
|
case <-done:
|
|
case <-time.After(timeout):
|
|
s.timeouts.Add(1)
|
|
logf("[burst] count=%d dispatch timeout received=%d", count, received.Load())
|
|
}
|
|
elapsed := float64(time.Since(started).Microseconds()) / 1000.0
|
|
if received.Load() != int64(count) {
|
|
s.pendingLeak.Add(1)
|
|
}
|
|
emitThroughput("burst", fmt.Sprintf("count=%d", count), transport, count, elapsed, 1, testDataPayloadBytes("b-0"), s, memMb())
|
|
_ = h.close()
|
|
if h.comm.IsAlive() {
|
|
s.pendingLeak.Add(1)
|
|
}
|
|
srv.stop()
|
|
cancel()
|
|
logf("[burst] transport=%s count=%d received=%d violations=%d", transport, count, received.Load(), s.violations())
|
|
}
|
|
}
|
|
|
|
func emitThroughput(profile, axis, transport string, count int, elapsedMs float64, clientCount, payloadBytes int, s *stability, observedMem float64) {
|
|
throughput := 0.0
|
|
if elapsedMs > 0 {
|
|
throughput = float64(count) / elapsedMs * 1000.0
|
|
}
|
|
emitRow(rowInput{
|
|
profile: profile,
|
|
axis: axis,
|
|
transport: transport,
|
|
payloadBytes: payloadBytes,
|
|
clientCount: clientCount,
|
|
requests: count,
|
|
throughput: throughput,
|
|
memMb: observedMem,
|
|
stab: s,
|
|
})
|
|
}
|
|
|
|
func profileSustained(transport string, mode string) {
|
|
durationsMs := []int{2000}
|
|
if mode == "full" {
|
|
durationsMs = []int{30000, 300000, 1800000}
|
|
}
|
|
concurrency := 16
|
|
timeout := 15 * time.Second
|
|
logf("[sustained] transport=%s mode=%s durations=%vms concurrency=%d", transport, mode, durationsMs, concurrency)
|
|
for _, durationMs := range durationsMs {
|
|
s := &stability{}
|
|
peak := memMb()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
srv, err := startRequestServer(ctx, transport, s)
|
|
if err != nil {
|
|
cancel()
|
|
s.timeouts.Add(1)
|
|
summarize("sustained", fmt.Sprintf("duration=%dms", durationMs), transport, nil, 0, 1, s, peak)
|
|
continue
|
|
}
|
|
h, err := dialWithRetry(transport, srv.port)
|
|
if err != nil {
|
|
s.timeouts.Add(1)
|
|
summarize("sustained", fmt.Sprintf("duration=%dms", durationMs), transport, nil, 0, 1, s, peak)
|
|
srv.stop()
|
|
cancel()
|
|
continue
|
|
}
|
|
var mu sync.Mutex
|
|
latencies := make([]float64, 0, 4096)
|
|
nextIndex := int32(0)
|
|
deadline := time.Now().Add(time.Duration(durationMs) * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < concurrency; i++ {
|
|
idx := nextIndex
|
|
nextIndex++
|
|
wg.Add(1)
|
|
go func(index int32) {
|
|
defer wg.Done()
|
|
started := time.Now()
|
|
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
|
|
h.comm, &packets.TestData{Index: index, Message: fmt.Sprintf("s-%d", index)}, timeout)
|
|
if err != nil {
|
|
classifyRequestError(err, s)
|
|
return
|
|
}
|
|
elapsed := float64(time.Since(started).Microseconds()) / 1000.0
|
|
mu.Lock()
|
|
latencies = append(latencies, elapsed)
|
|
mu.Unlock()
|
|
if res.GetIndex() != index*2 || res.GetMessage() != fmt.Sprintf("echo:s-%d", index) {
|
|
s.nonceMismatch.Add(1)
|
|
}
|
|
}(idx)
|
|
}
|
|
wg.Wait()
|
|
if cur := memMb(); cur > peak {
|
|
peak = cur
|
|
}
|
|
}
|
|
summarize("sustained", fmt.Sprintf("duration=%dms", durationMs), transport, latencies, float64(durationMs), 1, s, peak)
|
|
_ = h.close()
|
|
if h.comm.IsAlive() {
|
|
s.pendingLeak.Add(1)
|
|
}
|
|
srv.stop()
|
|
cancel()
|
|
logf("[sustained] transport=%s duration=%dms done peakMemMb=%.1f violations=%d", transport, durationMs, peak, s.violations())
|
|
}
|
|
}
|
|
|
|
func profileParallel(transport string, mode string) {
|
|
clientCounts := []int{4}
|
|
perClient := 50
|
|
if mode == "full" {
|
|
clientCounts = []int{16, 128, 512, 1024}
|
|
perClient = 500
|
|
}
|
|
concurrency := 8
|
|
timeout := 15 * time.Second
|
|
logf("[parallel] transport=%s mode=%s clients=%v perClient=%d", transport, mode, clientCounts, perClient)
|
|
for _, clientCount := range clientCounts {
|
|
s := &stability{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
srv, err := startRequestServer(ctx, transport, s)
|
|
if err != nil {
|
|
s.timeouts.Add(1)
|
|
summarize("parallel", fmt.Sprintf("clients=%d", clientCount), transport, nil, 0, clientCount, s, memMb())
|
|
cancel()
|
|
continue
|
|
}
|
|
handles := make([]*clientHandle, 0, clientCount)
|
|
for i := 0; i < clientCount; i++ {
|
|
h, err := dialWithRetry(transport, srv.port)
|
|
if err != nil {
|
|
s.timeouts.Add(1)
|
|
continue
|
|
}
|
|
handles = append(handles, h)
|
|
}
|
|
var mu sync.Mutex
|
|
allLatencies := make([]float64, 0, clientCount*perClient)
|
|
started := time.Now()
|
|
var wg sync.WaitGroup
|
|
for _, h := range handles {
|
|
wg.Add(1)
|
|
go func(h *clientHandle) {
|
|
defer wg.Done()
|
|
lat := runRequestLoad(h, perClient, concurrency, timeout, s)
|
|
mu.Lock()
|
|
allLatencies = append(allLatencies, lat...)
|
|
mu.Unlock()
|
|
}(h)
|
|
}
|
|
wg.Wait()
|
|
elapsed := float64(time.Since(started).Microseconds()) / 1000.0
|
|
summarize("parallel", fmt.Sprintf("clients=%d", clientCount), transport, allLatencies, elapsed, clientCount, s, memMb())
|
|
for _, h := range handles {
|
|
_ = h.close()
|
|
if h.comm.IsAlive() {
|
|
s.pendingLeak.Add(1)
|
|
}
|
|
}
|
|
srv.stop()
|
|
cancel()
|
|
logf("[parallel] transport=%s clients=%d done violations=%d", transport, clientCount, s.violations())
|
|
}
|
|
}
|
|
|
|
func profileSlowMix(transport string, mode string) {
|
|
clientCount := 4
|
|
perClient := 20
|
|
slowEvery := 5
|
|
delayMs := 50
|
|
if mode == "full" {
|
|
clientCount = 16
|
|
perClient = 200
|
|
slowEvery = 10
|
|
delayMs = 100
|
|
}
|
|
axis := fmt.Sprintf("clients=%d,slowEvery=%d,delayMs=%d", clientCount, slowEvery, delayMs)
|
|
logf("[slow-mix] transport=%s mode=%s %s perClient=%d", transport, mode, axis, perClient)
|
|
reason := "deferred: optimize Epic — Go public request API does not expose deterministic same-connection in-flight slow/fast response-arrival order; concurrent SendRequestTyped completion order is goroutine-scheduling dependent"
|
|
emitSkip("slow-mix", axis, transport, reason)
|
|
logf("[slow-mix] transport=%s deferred=%s", transport, reason)
|
|
}
|
|
|
|
// profilePayload는 payload size matrix를 측정한다. 동일 connection request-response를 1KB/64KB/1MB
|
|
// payload에서 돌리고, message field를 deterministic filler로 채워 실제 serialized payload bytes,
|
|
// payload별 p50/p95/p99 latency, throughput, peak heap, stability counters를 결과 row에 남긴다.
|
|
func profilePayload(transport string, mode string) {
|
|
sizes := payloadSizesQuick
|
|
concurrency := 4
|
|
requests := 20
|
|
timeout := 10 * time.Second
|
|
if mode == "full" {
|
|
sizes = payloadSizesFull
|
|
concurrency = 8
|
|
requests = 100
|
|
timeout = 30 * time.Second
|
|
}
|
|
labels := make([]string, len(sizes))
|
|
for i, sz := range sizes {
|
|
labels[i] = payloadLabel(sz)
|
|
}
|
|
logf("[payload] transport=%s mode=%s sizes=%s concurrency=%d requests/size=%d",
|
|
transport, mode, strings.Join(labels, ","), concurrency, requests)
|
|
|
|
for _, size := range sizes {
|
|
s := &stability{}
|
|
filler := payloadFiller(size)
|
|
expectedEcho := "echo:" + filler
|
|
payloadBytes := testDataPayloadBytes(filler)
|
|
axis := "payload=" + payloadLabel(size)
|
|
peak := memMb()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
srv, err := startRequestServer(ctx, transport, s)
|
|
if err != nil {
|
|
cancel()
|
|
s.timeouts.Add(1)
|
|
logf("[payload] size=%s server error=%v", payloadLabel(size), err)
|
|
emitRow(rowInput{profile: "payload", axis: axis, transport: transport, payloadBytes: payloadBytes, clientCount: 1, memMb: peak, stab: s})
|
|
continue
|
|
}
|
|
h, err := dialWithRetry(transport, srv.port)
|
|
if err != nil {
|
|
s.timeouts.Add(1)
|
|
logf("[payload] size=%s dial error=%v", payloadLabel(size), err)
|
|
emitRow(rowInput{profile: "payload", axis: axis, transport: transport, payloadBytes: payloadBytes, clientCount: 1, memMb: peak, stab: s})
|
|
srv.stop()
|
|
cancel()
|
|
continue
|
|
}
|
|
var mu sync.Mutex
|
|
latencies := make([]float64, 0, requests)
|
|
issued := 0
|
|
nextIndex := int32(0)
|
|
started := time.Now()
|
|
for issued < requests {
|
|
batchSize := concurrency
|
|
if requests-issued < batchSize {
|
|
batchSize = requests - issued
|
|
}
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < batchSize; i++ {
|
|
idx := nextIndex
|
|
nextIndex++
|
|
wg.Add(1)
|
|
go func(index int32) {
|
|
defer wg.Done()
|
|
reqStart := time.Now()
|
|
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
|
|
h.comm, &packets.TestData{Index: index, Message: filler}, timeout)
|
|
if err != nil {
|
|
classifyRequestError(err, s)
|
|
return
|
|
}
|
|
elapsed := float64(time.Since(reqStart).Microseconds()) / 1000.0
|
|
mu.Lock()
|
|
latencies = append(latencies, elapsed)
|
|
mu.Unlock()
|
|
if res.GetIndex() != index*2 || res.GetMessage() != expectedEcho {
|
|
s.nonceMismatch.Add(1)
|
|
}
|
|
}(idx)
|
|
}
|
|
issued += batchSize
|
|
wg.Wait()
|
|
if cur := memMb(); cur > peak {
|
|
peak = cur
|
|
}
|
|
}
|
|
elapsedMs := float64(time.Since(started).Microseconds()) / 1000.0
|
|
sorted := append([]float64(nil), latencies...)
|
|
sort.Float64s(sorted)
|
|
throughput := 0.0
|
|
if elapsedMs > 0 {
|
|
throughput = float64(len(latencies)) / elapsedMs * 1000.0
|
|
}
|
|
emitRow(rowInput{
|
|
profile: "payload",
|
|
axis: axis,
|
|
transport: transport,
|
|
payloadBytes: payloadBytes,
|
|
clientCount: 1,
|
|
requests: len(latencies),
|
|
throughput: throughput,
|
|
p50: percentile(sorted, 50),
|
|
p95: percentile(sorted, 95),
|
|
p99: percentile(sorted, 99),
|
|
memMb: peak,
|
|
stab: s,
|
|
})
|
|
_ = h.close()
|
|
if h.comm.IsAlive() {
|
|
s.pendingLeak.Add(1)
|
|
}
|
|
srv.stop()
|
|
cancel()
|
|
logf("[payload] transport=%s size=%s bytes=%d peakMemMb=%.1f violations=%d",
|
|
transport, payloadLabel(size), payloadBytes, peak, s.violations())
|
|
}
|
|
}
|
|
|
|
// mustPort는 burst 헬퍼에서 free port 확보 실패를 panic이 아니라 0으로 떨어뜨려 server start가 BLOCKED를 유도하게 한다.
|
|
func mustPort() int {
|
|
p, err := freePort()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return p
|
|
}
|
|
|
|
func parseListArg(argv []string, prefix string, def []string) []string {
|
|
for _, a := range argv {
|
|
if strings.HasPrefix(a, prefix) {
|
|
raw := strings.Split(a[len(prefix):], ",")
|
|
out := make([]string, 0, len(raw))
|
|
for _, v := range raw {
|
|
v = strings.TrimSpace(v)
|
|
if v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
return def
|
|
}
|
|
|
|
func main() {
|
|
argv := os.Args[1:]
|
|
mode := "quick"
|
|
for _, a := range argv {
|
|
switch {
|
|
case a == "--full" || a == "--mode=full":
|
|
mode = "full"
|
|
case a == "--quick" || a == "--mode=quick":
|
|
mode = "quick"
|
|
}
|
|
}
|
|
transports := parseListArg(argv, "--transport=", []string{transTCP, transWS})
|
|
transports = parseListArg(argv, "--transports=", transports)
|
|
profiles := parseListArg(argv, "--profiles=", allProfiles)
|
|
profiles = parseListArg(argv, "--profile=", profiles)
|
|
|
|
logf("INFO stress harness language=%s mode=%s transports=%s profiles=%s typeName=%s",
|
|
language, mode, strings.Join(transports, ","), strings.Join(profiles, ","), toki.TypeNameOf(&packets.TestData{}))
|
|
|
|
runners := map[string]func(string, string){
|
|
"roundtrip": profileRoundtrip,
|
|
"burst": profileBurst,
|
|
"sustained": profileSustained,
|
|
"parallel": profileParallel,
|
|
"slow-mix": profileSlowMix,
|
|
"payload": profilePayload,
|
|
}
|
|
|
|
for _, transport := range transports {
|
|
if transport != transTCP && transport != transWS {
|
|
emitSkip("all", "transport", transport, "unsupported transport for Go same-language baseline")
|
|
continue
|
|
}
|
|
for _, p := range profiles {
|
|
runner, ok := runners[p]
|
|
if !ok {
|
|
emitSkip(p, "profile", transport, "profile not part of Go same-language baseline (gateway is TypeScript-specific)")
|
|
continue
|
|
}
|
|
runner(transport, mode)
|
|
}
|
|
}
|
|
|
|
status := "PASS"
|
|
if totalViol != 0 {
|
|
status = "FAIL"
|
|
}
|
|
fmt.Printf("SUMMARY|status=%s|language=%s|mode=%s|transports=%s|profiles=%s|rows=%d|stability_violations=%d\n",
|
|
status, language, mode, strings.Join(transports, ","), strings.Join(profiles, ","), rowsEmitted, totalViol)
|
|
if status != "PASS" {
|
|
os.Exit(1)
|
|
}
|
|
}
|