diff --git a/README.md b/README.md index 6c79510..6f33c85 100644 --- a/README.md +++ b/README.md @@ -258,9 +258,12 @@ go run ./cmd/proxy-loadgen ` ``` 命令输出 JSON 报告,包含成功/失败分类、`TunnelsEstablished`、 -`ExtractResponsesValidated`、`ExtractReturned`、`ExtractValidationFailures`、固定内存的 +`ExtractResponsesValidated`、`ExtractReturned`、`ExtractValidationFailures`、`RateStartsGenerated`、 +`RateStartsDropped`、固定内存的 连接握手延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它不会输出提取响应中的地址或 -凭据,也不构成 100,000 QPS 证明。 +凭据,也不构成 100,000 QPS 证明。限速场景以 `Requests` 表示实际发起数;当 +`RateStartsDropped` 非零时,目标速率受压测端并发容量或时间窗限制,报告吞吐不得 +标注为已达到配置的 `-rate`。 ## 关键配置与入口 diff --git a/cmd/proxy-loadgen/main_test.go b/cmd/proxy-loadgen/main_test.go index 4dfc688..c6e8b41 100644 --- a/cmd/proxy-loadgen/main_test.go +++ b/cmd/proxy-loadgen/main_test.go @@ -18,7 +18,10 @@ func TestExecutePassesBoundedWorkloadAndWritesJSON(t *testing.T) { "-requests", "3", "-concurrency", "2", "-timeout", "2s", "-header", "X-Run: fixed", "-body", `{"count":1}`, }, func(_ context.Context, options loadgen.Options) (loadgen.Report, error) { received = options - return loadgen.Report{Requests: 3, Completed: 3, Succeeded: 3, Duration: time.Second}, nil + return loadgen.Report{ + Requests: 3, Completed: 3, Succeeded: 3, Duration: time.Second, + RateStartsGenerated: 3, RateStartsDropped: 1, + }, nil }, &stdout, &stderr) if code != 0 || received.TargetURL != "https://target.example/path" || received.ProxyURL != "http://gateway.example:8080" || received.Method != "post" || received.Requests != 3 || received.Duration != 0 || received.Concurrency != 2 || @@ -26,7 +29,8 @@ func TestExecutePassesBoundedWorkloadAndWritesJSON(t *testing.T) { t.Fatalf("execute() = %d; options=%+v stderr=%q", code, received, stderr.String()) } var report loadgen.Report - if err := json.Unmarshal(stdout.Bytes(), &report); err != nil || report.Succeeded != 3 { + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil || report.Succeeded != 3 || + report.RateStartsGenerated != 3 || report.RateStartsDropped != 1 { t.Fatalf("JSON report = (%+v, %v)", report, err) } } diff --git a/docs/testing/test-strategy.md b/docs/testing/test-strategy.md index f034030..fe88805 100644 --- a/docs/testing/test-strategy.md +++ b/docs/testing/test-strategy.md @@ -182,6 +182,7 @@ Admin 应用层测试覆盖 typed-nil 依赖、Actor/SourceIP 映射、Routing C 配置:Snapshot 规模、Proxy 容量、路由、重试、日志级别 场景:协议、连接复用、响应体、持续时间、升压曲线、故障注入 结果:QPS、建连速率、active、p50/p95/p99、错误、CPU、RSS、GC、FD、网络 +限速:配置 rate、实际 Requests、RateStartsGenerated、RateStartsDropped 不变量:capacity、ownership、extraction、idempotency、admin audit、reserve 检查结果 结论:通过/失败,以及适用边界 ``` diff --git a/internal/loadgen/http.go b/internal/loadgen/http.go index 76be38a..ae57b88 100644 --- a/internal/loadgen/http.go +++ b/internal/loadgen/http.go @@ -75,6 +75,8 @@ type Report struct { ExtractReturned uint64 ExtractValidationFailures uint64 Throughput float64 + RateStartsGenerated uint64 + RateStartsDropped uint64 Latency LatencyReport Runtime RuntimeReport } @@ -110,6 +112,8 @@ type counters struct { extractResponsesValidated atomic.Uint64 extractReturned atomic.Uint64 extractValidationFailures atomic.Uint64 + rateStartsGenerated atomic.Uint64 + rateStartsDropped atomic.Uint64 latency latencyHistogram } @@ -154,7 +158,7 @@ func Run(ctx context.Context, options Options) (Report, error) { return report(started, normalized.Scenario, stats), err } } else { - runForDuration(ctx, normalized, execute) + runForDuration(ctx, normalized, execute, stats) } return report(started, normalized.Scenario, stats), nil } @@ -285,7 +289,7 @@ func runFixed(ctx context.Context, options Options, execute func(context.Context workers.Wait() } -func runForDuration(ctx context.Context, options Options, execute func(context.Context)) { +func runForDuration(ctx context.Context, options Options, execute func(context.Context), stats *counters) { workloadContext, cancel := context.WithTimeout(ctx, options.Duration) defer cancel() if options.Rate == 0 { @@ -334,14 +338,20 @@ func runForDuration(ctx context.Context, options Options, execute func(context.C last = now tokens := int(credit) credit -= float64(tokens) + if tokens <= 0 { + continue + } + stats.rateStartsGenerated.Add(uint64(tokens)) if tokens > options.Concurrency { + stats.rateStartsDropped.Add(uint64(tokens - options.Concurrency)) tokens = options.Concurrency credit = 0 } - for range tokens { + for index := 0; index < tokens; index++ { select { case jobs <- struct{}{}: case <-workloadContext.Done(): + stats.rateStartsDropped.Add(uint64(tokens - index)) workers.Wait() return } @@ -655,7 +665,9 @@ func report(started time.Time, scenario Scenario, stats *counters) Report { Status5xx: stats.status5xx.Load(), TimeoutErrors: stats.timeoutErrors.Load(), RequestErrors: stats.requestErrors.Load(), TunnelsEstablished: stats.tunnelsEstablished.Load(), ExtractResponsesValidated: stats.extractResponsesValidated.Load(), ExtractReturned: stats.extractReturned.Load(), - ExtractValidationFailures: stats.extractValidationFailures.Load(), Latency: stats.latency.Report(completed), + ExtractValidationFailures: stats.extractValidationFailures.Load(), + RateStartsGenerated: stats.rateStartsGenerated.Load(), RateStartsDropped: stats.rateStartsDropped.Load(), + Latency: stats.latency.Report(completed), Runtime: RuntimeReport{NumCPU: runtime.NumCPU(), Goroutines: runtime.NumGoroutine(), AllocBytes: memory.Alloc, SysBytes: memory.Sys, GCCount: memory.NumGC}, } if duration > 0 { diff --git a/internal/loadgen/http_test.go b/internal/loadgen/http_test.go index eb5a0ca..d6d88a4 100644 --- a/internal/loadgen/http_test.go +++ b/internal/loadgen/http_test.go @@ -71,6 +71,32 @@ func TestRunTimeBoxedRateIsBounded(t *testing.T) { } } +func TestRunReportsRateStartsDroppedByConcurrency(t *testing.T) { + started := make(chan struct{}, 1) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + select { + case started <- struct{}{}: + default: + } + <-request.Context().Done() + response.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + report, err := Run(context.Background(), Options{ + TargetURL: server.URL, Duration: 80 * time.Millisecond, Rate: 100_000, Concurrency: 1, RequestTimeout: time.Second, + }) + select { + case <-started: + default: + t.Fatal("rate-limited workload did not start a request") + } + if err != nil || report.RateStartsGenerated <= report.Requests || report.RateStartsDropped == 0 || + report.RateStartsDropped > report.RateStartsGenerated { + t.Fatalf("Run() = (%+v, %v)", report, err) + } +} + func TestRunExecutesBoundedCONNECTTunnelWorkload(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil {