fix: account for queued loadgen rate starts

This commit is contained in:
youfak 2026-08-07 16:00:09 +08:00
parent 5ffd87f56b
commit d9d3a3d6c0
5 changed files with 54 additions and 4 deletions

View File

@ -263,7 +263,8 @@ go run ./cmd/proxy-loadgen `
连接握手延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它不会输出提取响应中的地址或 连接握手延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它不会输出提取响应中的地址或
凭据,也不构成 100,000 QPS 证明。限速场景以 `Requests` 表示实际发起数;当 凭据,也不构成 100,000 QPS 证明。限速场景以 `Requests` 表示实际发起数;当
`RateStartsDropped` 非零时,目标速率受压测端并发容量或时间窗限制,报告吞吐不得 `RateStartsDropped` 非零时,目标速率受压测端并发容量或时间窗限制,报告吞吐不得
标注为已达到配置的 `-rate` 标注为已达到配置的 `-rate`。每个生成的限速令牌均会计入实际发起或丢弃,因此
`RateStartsGenerated = Requests + RateStartsDropped`
需要保留单次容量证据时,使用 `-output REPORT_FILE` 同时写出 JSON 文件;文件在 需要保留单次容量证据时,使用 `-output REPORT_FILE` 同时写出 JSON 文件;文件在
同目录完整写入后才替换目标,标准输出仍保留相同报告,便于交给日志或指标系统。 同目录完整写入后才替换目标,标准输出仍保留相同报告,便于交给日志或指标系统。

View File

@ -21,6 +21,16 @@ data:
limits: limits:
maxConcurrentConnections: 100000 maxConcurrentConnections: 100000
requestsPerMinutePerClient: 60000 requestsPerMinutePerClient: 60000
transport:
dialTimeout: 10s
handshakeTimeout: 15s
responseHeaderTimeout: 30s
idleConnTimeout: 90s
maxIdleConns: 20000
maxIdleConnsPerHost: 32
maxConnsPerHost: 32
tunnelBufferBytes: 32768
tunnelIdleTimeout: 5m
retry: retry:
maxAttempts: 2 maxAttempts: 2
retryMethods: [GET, HEAD] retryMethods: [GET, HEAD]

View File

@ -204,6 +204,7 @@ func TestShippedDeploymentConfigurationsResolveEnvironment(t *testing.T) {
wantGatewayUser string wantGatewayUser string
wantPostgresURL string wantPostgresURL string
wantRedisURL string wantRedisURL string
wantTransport GatewayTransport
}{ }{
{ {
name: "local", name: "local",
@ -211,6 +212,12 @@ func TestShippedDeploymentConfigurationsResolveEnvironment(t *testing.T) {
wantGatewayUser: "local-gateway", wantGatewayUser: "local-gateway",
wantPostgresURL: "postgres://proxy_pool:local-only-change-me@postgres:5432/proxy_pool?sslmode=disable", wantPostgresURL: "postgres://proxy_pool:local-only-change-me@postgres:5432/proxy_pool?sslmode=disable",
wantRedisURL: "redis://redis:6379/0", wantRedisURL: "redis://redis:6379/0",
wantTransport: GatewayTransport{
MaxIdleConns: 20000,
MaxIdleConnsPerHost: 32,
MaxConnsPerHost: 32,
TunnelBufferBytes: 32768,
},
}, },
{ {
name: "kubernetes", name: "kubernetes",
@ -219,6 +226,17 @@ func TestShippedDeploymentConfigurationsResolveEnvironment(t *testing.T) {
wantGatewayUser: "resolved-gateway-user", wantGatewayUser: "resolved-gateway-user",
wantPostgresURL: "postgres://resolved", wantPostgresURL: "postgres://resolved",
wantRedisURL: "redis://resolved", wantRedisURL: "redis://resolved",
wantTransport: GatewayTransport{
DialTimeout: Duration(10 * time.Second),
HandshakeTimeout: Duration(15 * time.Second),
ResponseHeaderTimeout: Duration(30 * time.Second),
IdleConnTimeout: Duration(90 * time.Second),
MaxIdleConns: 20000,
MaxIdleConnsPerHost: 32,
MaxConnsPerHost: 32,
TunnelBufferBytes: 32768,
TunnelIdleTimeout: Duration(5 * time.Minute),
},
}, },
} }
for _, test := range tests { for _, test := range tests {
@ -248,6 +266,7 @@ func TestShippedDeploymentConfigurationsResolveEnvironment(t *testing.T) {
cfg.Admin.Auth.Token != "resolved-admin-token" || cfg.Admin.Auth.Token != "resolved-admin-token" ||
cfg.Storage.PostgresURL != test.wantPostgresURL || cfg.Storage.PostgresURL != test.wantPostgresURL ||
cfg.Storage.RedisURL != test.wantRedisURL || cfg.Storage.RedisURL != test.wantRedisURL ||
cfg.Gateway.Transport != test.wantTransport ||
cfg.Upstreams["provider-a"].API.Auth.Value != "resolved-provider-a-token" || cfg.Upstreams["provider-a"].API.Auth.Value != "resolved-provider-a-token" ||
cfg.Upstreams["provider-b"].API.Auth.Value != "resolved-provider-b-token" { cfg.Upstreams["provider-b"].API.Auth.Value != "resolved-provider-b-token" {
t.Fatalf("deployment values were not resolved: %+v", cfg.Redacted()) t.Fatalf("deployment values were not resolved: %+v", cfg.Redacted())

View File

@ -331,7 +331,7 @@ func runForDuration(ctx context.Context, options Options, execute func(context.C
for { for {
select { select {
case <-workloadContext.Done(): case <-workloadContext.Done():
workers.Wait() waitForRateWorkers(&workers, jobs, stats)
return return
case now := <-ticker.C: case now := <-ticker.C:
credit += float64(options.Rate) * now.Sub(last).Seconds() credit += float64(options.Rate) * now.Sub(last).Seconds()
@ -352,7 +352,7 @@ func runForDuration(ctx context.Context, options Options, execute func(context.C
case jobs <- struct{}{}: case jobs <- struct{}{}:
case <-workloadContext.Done(): case <-workloadContext.Done():
stats.rateStartsDropped.Add(uint64(tokens - index)) stats.rateStartsDropped.Add(uint64(tokens - index))
workers.Wait() waitForRateWorkers(&workers, jobs, stats)
return return
} }
} }
@ -360,6 +360,11 @@ func runForDuration(ctx context.Context, options Options, execute func(context.C
} }
} }
func waitForRateWorkers(workers *sync.WaitGroup, jobs <-chan struct{}, stats *counters) {
workers.Wait()
stats.rateStartsDropped.Add(uint64(len(jobs)))
}
type requestBuilder func(context.Context) (*http.Request, responseValidator, error) type requestBuilder func(context.Context) (*http.Request, responseValidator, error)
type responseValidator func(*http.Response) error type responseValidator func(*http.Response) error

View File

@ -66,11 +66,26 @@ func TestRunTimeBoxedRateIsBounded(t *testing.T) {
TargetURL: server.URL, Duration: 100 * time.Millisecond, Rate: 100, Concurrency: 2, RequestTimeout: time.Second, TargetURL: server.URL, Duration: 100 * time.Millisecond, Rate: 100, Concurrency: 2, RequestTimeout: time.Second,
}) })
if err != nil || report.Requests == 0 || report.Requests > 20 || report.Requests != report.Completed || if err != nil || report.Requests == 0 || report.Requests > 20 || report.Requests != report.Completed ||
report.Succeeded != report.Completed || requests.Load() != int64(report.Requests) { report.Succeeded+report.Failed != report.Completed || report.RateStartsGenerated != report.Requests+report.RateStartsDropped ||
requests.Load() > int64(report.Requests) {
t.Fatalf("Run() = (%+v, %v); handler requests=%d", report, err, requests.Load()) t.Fatalf("Run() = (%+v, %v); handler requests=%d", report, err, requests.Load())
} }
} }
func TestRunForDurationAccountsQueuedRateStartsAtDeadline(t *testing.T) {
stats := &counters{}
runForDuration(context.Background(), Options{
Duration: 40 * time.Millisecond, Rate: 1000, Concurrency: 1,
}, func(ctx context.Context) {
stats.requests.Add(1)
<-ctx.Done()
}, stats)
if generated, accounted := stats.rateStartsGenerated.Load(), stats.requests.Load()+stats.rateStartsDropped.Load(); generated == 0 || generated != accounted {
t.Fatalf("rate starts generated=%d, accounted=%d", generated, accounted)
}
}
func TestRunReportsRateStartsDroppedByConcurrency(t *testing.T) { func TestRunReportsRateStartsDroppedByConcurrency(t *testing.T) {
started := make(chan struct{}, 1) started := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {