feat: add bounded HTTP load generator
This commit is contained in:
parent
c7e77a6f84
commit
8fdeb0a905
18
README.md
18
README.md
@ -93,7 +93,7 @@ flowchart LR
|
|||||||
Observation 状态归并。
|
Observation 状态归并。
|
||||||
- **部分完成**:EGRESS 与 TARGET 的任务编排和配置建模,Docker Compose/Kubernetes
|
- **部分完成**:EGRESS 与 TARGET 的任务编排和配置建模,Docker Compose/Kubernetes
|
||||||
运行时 mTLS Overlay。
|
运行时 mTLS Overlay。
|
||||||
- **待完成**:loadgen、故障演练和代表性集群压测。
|
- **待完成**:CONNECT 长连接/Extract 压测场景、故障演练和代表性集群压测。
|
||||||
|
|
||||||
检查项数量不等于生产就绪度。静态部署清单与 protobuf descriptor 验证也不代表
|
检查项数量不等于生产就绪度。静态部署清单与 protobuf descriptor 验证也不代表
|
||||||
端到端拓扑已经完成;`100,000 QPS` 仍只是待验证的集群设计目标。
|
端到端拓扑已经完成;`100,000 QPS` 仍只是待验证的集群设计目标。
|
||||||
@ -171,7 +171,21 @@ Checker 的参数也可通过 `PROXY_POOL_CONTROL_PLANE_ADDRESS`、
|
|||||||
Controller 在启用控制面时装配 Redis 共享任务 broker,并按启用的 Upstream 调度
|
Controller 在启用控制面时装配 Redis 共享任务 broker,并按启用的 Upstream 调度
|
||||||
HTTP/HTTPS/SOCKS5 BASIC 检查。调度监督器每轮读取已发布配置,因此 reload 后的上游启停、
|
HTTP/HTTPS/SOCKS5 BASIC 检查。调度监督器每轮读取已发布配置,因此 reload 后的上游启停、
|
||||||
检查间隔、抖动、超时、重试次数和 `maxInFlight` 会在下一轮生效;新启用的上游无需
|
检查间隔、抖动、超时、重试次数和 `maxInFlight` 会在下一轮生效;新启用的上游无需
|
||||||
重启 Controller。EGRESS 与 TARGET 尚未进入生产调度,loadgen 命令也尚未实现。
|
重启 Controller。EGRESS 与 TARGET 尚未进入生产调度。
|
||||||
|
|
||||||
|
初版 HTTP 容量工具可按固定请求数或固定时长运行,并将 HTTPS 目标经 Gateway 的请求
|
||||||
|
交给标准 HTTP Transport 建立 CONNECT:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./cmd/proxy-loadgen `
|
||||||
|
-target https://TARGET_URL/health `
|
||||||
|
-proxy http://GATEWAY_HOST:8080 `
|
||||||
|
-requests 10000 -concurrency 128 -timeout 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `-duration 30s -rate 5000` 可运行限速场景;省略 `-rate` 时固定数量 worker
|
||||||
|
会饱和发送。命令输出 JSON 报告,包含成功/失败分类、固定内存的延迟分位上界、吞吐和
|
||||||
|
Go 运行时内存/GC 快照。它不包含长连接保持或 Extract 场景,也不构成 100,000 QPS 证明。
|
||||||
|
|
||||||
## 关键配置与入口
|
## 关键配置与入口
|
||||||
|
|
||||||
|
|||||||
101
cmd/proxy-loadgen/main.go
Normal file
101
cmd/proxy-loadgen/main.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"proxy-pool/internal/loadgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loadRun func(context.Context, loadgen.Options) (loadgen.Report, error)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
os.Exit(execute(ctx, os.Args[1:], loadgen.Run, os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.Writer) int {
|
||||||
|
flags := flag.NewFlagSet("proxy-loadgen", flag.ContinueOnError)
|
||||||
|
flags.SetOutput(stderr)
|
||||||
|
targetURL := flags.String("target", "", "HTTP or HTTPS target URL")
|
||||||
|
proxyURL := flags.String("proxy", "", "optional HTTP or HTTPS forward proxy URL")
|
||||||
|
method := flags.String("method", http.MethodGet, "HTTP method")
|
||||||
|
requests := flags.Int("requests", 0, "fixed request count; mutually exclusive with -duration")
|
||||||
|
duration := flags.Duration("duration", 0, "time-boxed workload duration")
|
||||||
|
rate := flags.Int("rate", 0, "maximum request starts per second for -duration; zero saturates workers")
|
||||||
|
concurrency := flags.Int("concurrency", 64, "maximum concurrent requests")
|
||||||
|
timeout := flags.Duration("timeout", 10*time.Second, "per-request timeout")
|
||||||
|
var headers headerValues
|
||||||
|
flags.Var(&headers, "header", "repeatable HTTP header in Name: Value form")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if flags.NArg() != 0 || ctx == nil || run == nil {
|
||||||
|
_, _ = fmt.Fprintln(stderr, "proxy-loadgen: target and a bounded workload are required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
parsedHeaders, err := headers.Header()
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "proxy-loadgen: %v\n", err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
report, err := run(ctx, loadgen.Options{
|
||||||
|
TargetURL: *targetURL, ProxyURL: *proxyURL, Method: *method, Headers: parsedHeaders,
|
||||||
|
Requests: *requests, Duration: *duration, Rate: *rate, Concurrency: *concurrency, RequestTimeout: *timeout,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, loadgen.ErrInvalidOptions) {
|
||||||
|
_, _ = fmt.Fprintln(stderr, "proxy-loadgen: -target, positive -concurrency/-timeout, and exactly one bounded workload are required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(stderr, "proxy-loadgen: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := json.NewEncoder(stdout).Encode(report); err != nil {
|
||||||
|
_, _ = fmt.Fprintf(stderr, "proxy-loadgen: encode report: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type headerValues []string
|
||||||
|
|
||||||
|
func (values *headerValues) String() string {
|
||||||
|
return strings.Join(*values, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (values *headerValues) Set(value string) error {
|
||||||
|
if strings.TrimSpace(value) != value || value == "" {
|
||||||
|
return errors.New("invalid header")
|
||||||
|
}
|
||||||
|
*values = append(*values, value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (values headerValues) Header() (http.Header, error) {
|
||||||
|
result := make(http.Header, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
name, content, found := strings.Cut(value, ":")
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
content = strings.TrimSpace(content)
|
||||||
|
if !found || name == "" || strings.ContainsAny(name, "\r\n") || strings.ContainsAny(content, "\r\n") {
|
||||||
|
return nil, errors.New("invalid header")
|
||||||
|
}
|
||||||
|
result.Add(name, content)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
40
cmd/proxy-loadgen/main_test.go
Normal file
40
cmd/proxy-loadgen/main_test.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"proxy-pool/internal/loadgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecutePassesBoundedWorkloadAndWritesJSON(t *testing.T) {
|
||||||
|
var received loadgen.Options
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := execute(context.Background(), []string{
|
||||||
|
"-target", "https://target.example/path", "-proxy", "http://gateway.example:8080", "-method", "post",
|
||||||
|
"-requests", "3", "-concurrency", "2", "-timeout", "2s", "-header", "X-Run: fixed",
|
||||||
|
}, func(_ context.Context, options loadgen.Options) (loadgen.Report, error) {
|
||||||
|
received = options
|
||||||
|
return loadgen.Report{Requests: 3, Completed: 3, Succeeded: 3, Duration: time.Second}, 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 ||
|
||||||
|
received.Headers.Get("X-Run") != "fixed" || stderr.Len() != 0 {
|
||||||
|
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 {
|
||||||
|
t.Fatalf("JSON report = (%+v, %v)", report, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRejectsInvalidWorkload(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := execute(context.Background(), []string{"-target", "http://target.example"}, loadgen.Run, &stdout, &stderr)
|
||||||
|
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||||
|
t.Fatalf("execute() = %d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -79,8 +79,10 @@ Provider、Pool、Routing、Distribution 在首版需要共享事务和一致性
|
|||||||
|
|
||||||
### 3.4 proxy-loadgen
|
### 3.4 proxy-loadgen
|
||||||
|
|
||||||
- 分别生成 HTTP QPS、CONNECT 活跃连接、建连速率和 Extract 并发。
|
- 当前实现 HTTP 请求场景:固定请求数或固定时长,受限并发与可选目标 QPS;HTTPS
|
||||||
- 输出环境、场景、延迟、错误、CPU、RSS、句柄和网络结果。
|
目标经 HTTP Gateway 时由 Transport 走 CONNECT。
|
||||||
|
- 输出场景、延迟分位上界、错误分类、吞吐和 Go 内存/GC 快照。CONNECT 长连接、
|
||||||
|
Extract 并发、进程 CPU/RSS/句柄与网络采样仍需补齐。
|
||||||
|
|
||||||
## 4. 模块边界
|
## 4. 模块边界
|
||||||
|
|
||||||
|
|||||||
@ -53,9 +53,10 @@ deadline 内执行 HTTP/HTTPS/SOCKS5 BASIC、EGRESS 和 TARGET 探测并微批
|
|||||||
|
|
||||||
### proxy-loadgen
|
### proxy-loadgen
|
||||||
|
|
||||||
负载工具生成 HTTP、CONNECT、连接复用与故障注入场景,输出延迟分位数、
|
负载工具当前生成有界 HTTP 请求,支持经 Gateway 请求 HTTPS 目标、固定请求数或时长、
|
||||||
错误类别、连接数、CPU、RSS、GC 与吞吐。它是 100k QPS 结论的证据工具,
|
目标 QPS、连接复用和 JSON 指标输出。延迟统计使用固定大小直方图,不会因长时间高 QPS
|
||||||
不是业务进程。
|
运行积压样本。CONNECT 长连接、Extract 和故障注入场景仍待补齐;它是 100k QPS 结论的
|
||||||
|
证据工具,不是业务进程。
|
||||||
|
|
||||||
## 3. 依赖方向
|
## 3. 依赖方向
|
||||||
|
|
||||||
|
|||||||
@ -293,6 +293,11 @@ EGRESS/TARGET 多维任务索引及部署运行态仍未实现,
|
|||||||
有界派发逻辑,所以 reload 后已启用上游的策略变更、停用,以及新启用上游都无需重启
|
有界派发逻辑,所以 reload 后已启用上游的策略变更、停用,以及新启用上游都无需重启
|
||||||
Controller 即可生效;Redis 任务存储仍仅承载 BASIC,未扩展 EGRESS/TARGET 的多维索引。
|
Controller 即可生效;Redis 任务存储仍仅承载 BASIC,未扩展 EGRESS/TARGET 的多维索引。
|
||||||
|
|
||||||
|
补充进度(2026-08-02):已新增 `proxy-loadgen` HTTP 场景。固定请求数和固定时长两种
|
||||||
|
模式均通过固定 worker 数与有界派发通道执行,可选 QPS 限速;报告使用固定大小延迟直方图,
|
||||||
|
输出状态分类、吞吐和 Go 内存/GC 快照。CONNECT 长连接、Extract、故障注入以及代表性集群
|
||||||
|
报告仍未实现。
|
||||||
|
|
||||||
## Task 12: Machine-readable Contracts
|
## Task 12: Machine-readable Contracts
|
||||||
|
|
||||||
**Files:** `api/openapi/proxy-pool.yaml`, `api/proto/controlplane/v1/controlplane.proto`,
|
**Files:** `api/openapi/proxy-pool.yaml`, `api/proto/controlplane/v1/controlplane.proto`,
|
||||||
|
|||||||
@ -24,8 +24,9 @@
|
|||||||
`cmd/proxy-controller` 已完成配置单次加载、PostgreSQL 迁移、Redis 活动池、
|
`cmd/proxy-controller` 已完成配置单次加载、PostgreSQL 迁移、Redis 活动池、
|
||||||
Distribution/Admin/Metrics 独立监听和有界停机装配。Provider 自动补池、分布式
|
Distribution/Admin/Metrics 独立监听和有界停机装配。Provider 自动补池、分布式
|
||||||
配额、动态重载和 Admin 低基数统计已装配;Controller 已装配 Redis BASIC 任务 broker,
|
配额、动态重载和 Admin 低基数统计已装配;Controller 已装配 Redis BASIC 任务 broker,
|
||||||
`proxy-checker` 可执行 HTTP/HTTPS/SOCKS5 BASIC 探测。EGRESS/TARGET 调度、loadgen 与完整
|
`proxy-checker` 可执行 HTTP/HTTPS/SOCKS5 BASIC 探测。`proxy-loadgen` 已提供有界 HTTP
|
||||||
mTLS 环境 Overlay 仍属于 `implementation-plan.md` 后续任务。
|
请求场景;EGRESS/TARGET 调度、CONNECT 长连接/Extract 压测与完整 mTLS 环境 Overlay 仍属于
|
||||||
|
`implementation-plan.md` 后续任务。
|
||||||
因此 Compose/Kubernetes 资产当前仍用于评审网络、资源、探针和依赖关系,不能
|
因此 Compose/Kubernetes 资产当前仍用于评审网络、资源、探针和依赖关系,不能
|
||||||
视为完整可运行拓扑。
|
视为完整可运行拓扑。
|
||||||
|
|
||||||
|
|||||||
@ -99,7 +99,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
|
|||||||
|
|
||||||
以下已有设计、接口或部署位置,但尚无端到端生产实现:
|
以下已有设计、接口或部署位置,但尚无端到端生产实现:
|
||||||
|
|
||||||
1. `proxy-loadgen` 进程装配;`proxy-checker` 的 BASIC 任务进程已经完成,
|
1. `proxy-loadgen` 已提供有界 HTTP 请求进程;`proxy-checker` 的 BASIC 任务进程已经完成,
|
||||||
`proxy-controller` 已完成
|
`proxy-controller` 已完成
|
||||||
Admin/Distribution/Metrics 与 PostgreSQL/Redis 启动装配,`proxy-gateway` 已完成
|
Admin/Distribution/Metrics 与 PostgreSQL/Redis 启动装配,`proxy-gateway` 已完成
|
||||||
HTTP/Metrics 与控制面 Session 装配,但 Provider 和业务指标链未闭环。
|
HTTP/Metrics 与控制面 Session 装配,但 Provider 和业务指标链未闭环。
|
||||||
@ -118,7 +118,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
|
|||||||
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成;EGRESS、TARGET
|
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成;EGRESS、TARGET
|
||||||
生产任务调度与 REMOVE 编排仍待实现。
|
生产任务调度与 REMOVE 编排仍待实现。
|
||||||
8. Admin/Distribution 细粒度授权和审计查询;Distribution 分布式限流已完成。
|
8. Admin/Distribution 细粒度授权和审计查询;Distribution 分布式限流已完成。
|
||||||
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
9. CONNECT 长连接/Extract 场景、真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
||||||
10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS
|
10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS
|
||||||
和 disabled candidate 语义。
|
和 disabled candidate 语义。
|
||||||
11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测;短 TTL
|
11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测;短 TTL
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
| ID | 最终需求 | 来源 | 验证证据 |
|
| ID | 最终需求 | 来源 | 验证证据 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离;Controller 已运行 Worker Register/Watch/ACK/Runtime/Outcome 与 Checker Observation gRPC,并发布 Proxy/Gateway Routing/按引用去重凭据完整快照;Checker 任务流已具备有界领取、租约栅栏和任务期凭据契约。Gateway 已将快照编译为同版本动态 View,并由独立进程维护控制面会话。`proxy-checker` 与 Redis BASIC 共享任务运行态已实现;loadgen、EGRESS/TARGET 生产编排待实现 |
|
| ARCH-001 | 数据面 Worker 与控制面 Controller 分离 | 1-70 | 包、协议和部署拓扑已分离;Controller 已运行 Worker Register/Watch/ACK/Runtime/Outcome 与 Checker Observation gRPC,并发布 Proxy/Gateway Routing/按引用去重凭据完整快照;Checker 任务流已具备有界领取、租约栅栏和任务期凭据契约。Gateway 已将快照编译为同版本动态 View,并由独立进程维护控制面会话。`proxy-checker` 与 Redis BASIC 共享任务运行态、`proxy-loadgen` 有界 HTTP 场景均已实现;EGRESS/TARGET 生产编排待实现 |
|
||||||
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Gateway bootstrap 集成测试验证启动期控制面会话与快照就绪,HTTP 请求只走本地 Snapshot/Dispatch;Outcome 仅写入有界非阻塞本地队列,代表性性能剖析待完成 |
|
| ARCH-002 | 热路径只做认证、本地路由和网络转发 | 1-70, 380-430 | Gateway bootstrap 集成测试验证启动期控制面会话与快照就绪,HTTP 请求只走本地 Snapshot/Dispatch;Outcome 仅写入有界非阻塞本地队列,代表性性能剖析待完成 |
|
||||||
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Controller 命令已装配 Distribution/Admin/Metrics 三个独立监听及联动停机;Gateway 命令已装配代理与 Metrics 监听,运行时 mTLS 部署 Overlay 待完成 |
|
| ARCH-003 | Gateway、Distribution、Admin、Metrics 独立入口 | 8904-8958 | Controller 命令已装配 Distribution/Admin/Metrics 三个独立监听及联动停机;Gateway 命令已装配代理与 Metrics 监听,运行时 mTLS 部署 Overlay 待完成 |
|
||||||
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Redis Leader、动态 Provider Supervisor 与 Bootstrap 生产装配已完成;Admin disable/reload 驱动取消替换,多副本按权威 HMAC 指纹和 revision 栅栏收敛并拒绝旧配置换主;Routing 切换到 Drain 的编排待完成 |
|
| ARCH-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Redis Leader、动态 Provider Supervisor 与 Bootstrap 生产装配已完成;Admin disable/reload 驱动取消替换,多副本按权威 HMAC 指纹和 revision 栅栏收敛并拒绝旧配置换主;Routing 切换到 Drain 的编排待完成 |
|
||||||
|
|||||||
339
internal/loadgen/http.go
Normal file
339
internal/loadgen/http.go
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
// Package loadgen runs bounded, reproducible HTTP request workloads. It is a
|
||||||
|
// capacity-evidence tool and is deliberately separate from service processes.
|
||||||
|
package loadgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"math/bits"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidOptions = errors.New("invalid load generator options")
|
||||||
|
|
||||||
|
const maximumResponseDrainBytes = 64 << 10
|
||||||
|
|
||||||
|
// Options bounds one HTTP workload. Requests selects a fixed-size run;
|
||||||
|
// otherwise Duration selects a time-boxed run and Rate caps its start rate.
|
||||||
|
type Options struct {
|
||||||
|
TargetURL string
|
||||||
|
ProxyURL string
|
||||||
|
Method string
|
||||||
|
Headers http.Header
|
||||||
|
Requests int
|
||||||
|
Duration time.Duration
|
||||||
|
Rate int
|
||||||
|
Concurrency int
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
StartedAt time.Time
|
||||||
|
Duration time.Duration
|
||||||
|
Requests uint64
|
||||||
|
Completed uint64
|
||||||
|
Succeeded uint64
|
||||||
|
Failed uint64
|
||||||
|
Status4xx uint64
|
||||||
|
Status5xx uint64
|
||||||
|
TimeoutErrors uint64
|
||||||
|
RequestErrors uint64
|
||||||
|
Throughput float64
|
||||||
|
Latency LatencyReport
|
||||||
|
Runtime RuntimeReport
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyReport uses logarithmic microsecond buckets. Percentiles are upper
|
||||||
|
// bounds so the collector remains fixed-size even under high QPS.
|
||||||
|
type LatencyReport struct {
|
||||||
|
Samples uint64
|
||||||
|
P50UpperBound time.Duration
|
||||||
|
P95UpperBound time.Duration
|
||||||
|
P99UpperBound time.Duration
|
||||||
|
MaximumUpperBound time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeReport struct {
|
||||||
|
NumCPU int
|
||||||
|
Goroutines int
|
||||||
|
AllocBytes uint64
|
||||||
|
SysBytes uint64
|
||||||
|
GCCount uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type counters struct {
|
||||||
|
requests atomic.Uint64
|
||||||
|
completed atomic.Uint64
|
||||||
|
succeeded atomic.Uint64
|
||||||
|
failed atomic.Uint64
|
||||||
|
status4xx atomic.Uint64
|
||||||
|
status5xx atomic.Uint64
|
||||||
|
timeoutErrors atomic.Uint64
|
||||||
|
requestErrors atomic.Uint64
|
||||||
|
latency latencyHistogram
|
||||||
|
}
|
||||||
|
|
||||||
|
type latencyHistogram struct {
|
||||||
|
buckets [64]atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func Run(ctx context.Context, options Options) (Report, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
return Report{}, ErrInvalidOptions
|
||||||
|
}
|
||||||
|
client, normalized, err := newClient(options)
|
||||||
|
if err != nil {
|
||||||
|
return Report{}, err
|
||||||
|
}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
stats := &counters{}
|
||||||
|
if normalized.Requests > 0 {
|
||||||
|
runFixed(ctx, client, normalized, stats)
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return report(started, stats), err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runForDuration(ctx, client, normalized, stats)
|
||||||
|
}
|
||||||
|
return report(started, stats), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(options Options) (*http.Client, Options, error) {
|
||||||
|
normalized := options
|
||||||
|
normalized.Method = strings.ToUpper(strings.TrimSpace(normalized.Method))
|
||||||
|
if normalized.Method == "" {
|
||||||
|
normalized.Method = http.MethodGet
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(normalized.TargetURL) != normalized.TargetURL || normalized.TargetURL == "" ||
|
||||||
|
normalized.Concurrency <= 0 || normalized.RequestTimeout <= 0 || normalized.Requests < 0 || normalized.Duration < 0 ||
|
||||||
|
normalized.Rate < 0 || (normalized.Requests == 0 && normalized.Duration <= 0) ||
|
||||||
|
(normalized.Requests > 0 && (normalized.Duration != 0 || normalized.Rate != 0)) {
|
||||||
|
return nil, Options{}, ErrInvalidOptions
|
||||||
|
}
|
||||||
|
target, err := url.Parse(normalized.TargetURL)
|
||||||
|
if err != nil || target.Scheme == "" || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") {
|
||||||
|
return nil, Options{}, ErrInvalidOptions
|
||||||
|
}
|
||||||
|
var proxy func(*http.Request) (*url.URL, error)
|
||||||
|
if normalized.ProxyURL != "" {
|
||||||
|
if strings.TrimSpace(normalized.ProxyURL) != normalized.ProxyURL {
|
||||||
|
return nil, Options{}, ErrInvalidOptions
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(normalized.ProxyURL)
|
||||||
|
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||||
|
return nil, Options{}, ErrInvalidOptions
|
||||||
|
}
|
||||||
|
proxy = http.ProxyURL(parsed)
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
Proxy: proxy,
|
||||||
|
ForceAttemptHTTP2: false,
|
||||||
|
MaxConnsPerHost: normalized.Concurrency,
|
||||||
|
MaxIdleConns: normalized.Concurrency,
|
||||||
|
MaxIdleConnsPerHost: normalized.Concurrency,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
TLSHandshakeTimeout: normalized.RequestTimeout,
|
||||||
|
ResponseHeaderTimeout: normalized.RequestTimeout,
|
||||||
|
}
|
||||||
|
return &http.Client{Transport: transport}, normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFixed(ctx context.Context, client *http.Client, options Options, stats *counters) {
|
||||||
|
jobs := make(chan struct{}, min(options.Concurrency, options.Requests))
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
for range options.Concurrency {
|
||||||
|
workers.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer workers.Done()
|
||||||
|
for range jobs {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
executeOne(ctx, client, options, stats)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for range options.Requests {
|
||||||
|
select {
|
||||||
|
case jobs <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
close(jobs)
|
||||||
|
workers.Wait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(jobs)
|
||||||
|
workers.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runForDuration(ctx context.Context, client *http.Client, options Options, stats *counters) {
|
||||||
|
workloadContext, cancel := context.WithTimeout(ctx, options.Duration)
|
||||||
|
defer cancel()
|
||||||
|
if options.Rate == 0 {
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
for range options.Concurrency {
|
||||||
|
workers.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer workers.Done()
|
||||||
|
for workloadContext.Err() == nil {
|
||||||
|
executeOne(workloadContext, client, options, stats)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
workers.Wait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := make(chan struct{}, options.Concurrency)
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
for range options.Concurrency {
|
||||||
|
workers.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer workers.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-workloadContext.Done():
|
||||||
|
return
|
||||||
|
case <-jobs:
|
||||||
|
executeOne(workloadContext, client, options, stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
last := time.Now()
|
||||||
|
credit := 0.0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-workloadContext.Done():
|
||||||
|
workers.Wait()
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
credit += float64(options.Rate) * now.Sub(last).Seconds()
|
||||||
|
last = now
|
||||||
|
tokens := int(credit)
|
||||||
|
credit -= float64(tokens)
|
||||||
|
if tokens > options.Concurrency {
|
||||||
|
tokens = options.Concurrency
|
||||||
|
credit = 0
|
||||||
|
}
|
||||||
|
for range tokens {
|
||||||
|
select {
|
||||||
|
case jobs <- struct{}{}:
|
||||||
|
case <-workloadContext.Done():
|
||||||
|
workers.Wait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeOne(ctx context.Context, client *http.Client, options Options, stats *counters) {
|
||||||
|
stats.requests.Add(1)
|
||||||
|
started := time.Now()
|
||||||
|
requestContext, cancel := context.WithTimeout(ctx, options.RequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
request, err := http.NewRequestWithContext(requestContext, options.Method, options.TargetURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
stats.failed.Add(1)
|
||||||
|
stats.requestErrors.Add(1)
|
||||||
|
stats.completed.Add(1)
|
||||||
|
stats.latency.Record(time.Since(started))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header = options.Headers.Clone()
|
||||||
|
response, err := client.Do(request)
|
||||||
|
latency := time.Since(started)
|
||||||
|
stats.latency.Record(latency)
|
||||||
|
stats.completed.Add(1)
|
||||||
|
if err != nil {
|
||||||
|
stats.failed.Add(1)
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(requestContext.Err(), context.DeadlineExceeded) {
|
||||||
|
stats.timeoutErrors.Add(1)
|
||||||
|
} else {
|
||||||
|
stats.requestErrors.Add(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maximumResponseDrainBytes))
|
||||||
|
_ = response.Body.Close()
|
||||||
|
switch {
|
||||||
|
case response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices:
|
||||||
|
stats.succeeded.Add(1)
|
||||||
|
case response.StatusCode >= http.StatusInternalServerError:
|
||||||
|
stats.failed.Add(1)
|
||||||
|
stats.status5xx.Add(1)
|
||||||
|
case response.StatusCode >= http.StatusBadRequest:
|
||||||
|
stats.failed.Add(1)
|
||||||
|
stats.status4xx.Add(1)
|
||||||
|
default:
|
||||||
|
stats.failed.Add(1)
|
||||||
|
stats.requestErrors.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func report(started time.Time, stats *counters) Report {
|
||||||
|
duration := time.Since(started)
|
||||||
|
completed := stats.completed.Load()
|
||||||
|
var memory runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memory)
|
||||||
|
result := Report{
|
||||||
|
StartedAt: started.UTC(), Duration: duration, Requests: stats.requests.Load(), Completed: completed,
|
||||||
|
Succeeded: stats.succeeded.Load(), Failed: stats.failed.Load(), Status4xx: stats.status4xx.Load(),
|
||||||
|
Status5xx: stats.status5xx.Load(), TimeoutErrors: stats.timeoutErrors.Load(), RequestErrors: stats.requestErrors.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 {
|
||||||
|
result.Throughput = float64(completed) / duration.Seconds()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (histogram *latencyHistogram) Record(latency time.Duration) {
|
||||||
|
microseconds := latency.Microseconds()
|
||||||
|
if microseconds < 1 {
|
||||||
|
microseconds = 1
|
||||||
|
}
|
||||||
|
index := bits.Len64(uint64(microseconds - 1))
|
||||||
|
if index >= len(histogram.buckets) {
|
||||||
|
index = len(histogram.buckets) - 1
|
||||||
|
}
|
||||||
|
histogram.buckets[index].Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (histogram *latencyHistogram) Report(samples uint64) LatencyReport {
|
||||||
|
result := LatencyReport{Samples: samples}
|
||||||
|
if samples == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.P50UpperBound = histogram.percentile(samples, 50)
|
||||||
|
result.P95UpperBound = histogram.percentile(samples, 95)
|
||||||
|
result.P99UpperBound = histogram.percentile(samples, 99)
|
||||||
|
result.MaximumUpperBound = histogram.percentile(samples, 100)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (histogram *latencyHistogram) percentile(samples uint64, percentile uint64) time.Duration {
|
||||||
|
target := (samples*percentile + 99) / 100
|
||||||
|
seen := uint64(0)
|
||||||
|
for index := range histogram.buckets {
|
||||||
|
seen += histogram.buckets[index].Load()
|
||||||
|
if seen >= target {
|
||||||
|
return time.Duration(uint64(1)<<index) * time.Microsecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Duration(1<<63 - 1)
|
||||||
|
}
|
||||||
70
internal/loadgen/http_test.go
Normal file
70
internal/loadgen/http_test.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package loadgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunExecutesFixedBoundedHTTPWorkload(t *testing.T) {
|
||||||
|
var requests atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||||
|
if request.Method != http.MethodPost || request.Header.Get("X-Scenario") != "fixed" {
|
||||||
|
t.Errorf("request = %s %q", request.Method, request.Header.Get("X-Scenario"))
|
||||||
|
}
|
||||||
|
requests.Add(1)
|
||||||
|
response.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
report, err := Run(context.Background(), Options{
|
||||||
|
TargetURL: server.URL, Method: http.MethodPost, Headers: http.Header{"X-Scenario": {"fixed"}},
|
||||||
|
Requests: 12, Concurrency: 3, RequestTimeout: time.Second,
|
||||||
|
})
|
||||||
|
if err != nil || report.Requests != 12 || report.Completed != 12 || report.Succeeded != 12 || report.Failed != 0 ||
|
||||||
|
requests.Load() != 12 || report.Latency.P50UpperBound <= 0 || report.Runtime.NumCPU <= 0 {
|
||||||
|
t.Fatalf("Run() = (%+v, %v); handler requests=%d", report, err, requests.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunClassifiesHTTPFailure(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||||
|
response.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
report, err := Run(context.Background(), Options{
|
||||||
|
TargetURL: server.URL, Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
|
||||||
|
})
|
||||||
|
if err != nil || report.Succeeded != 0 || report.Failed != 1 || report.Status5xx != 1 || report.Completed != 1 {
|
||||||
|
t.Fatalf("Run() = (%+v, %v)", report, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTimeBoxedRateIsBounded(t *testing.T) {
|
||||||
|
var requests atomic.Int64
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
response.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
report, err := Run(context.Background(), Options{
|
||||||
|
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 ||
|
||||||
|
report.Succeeded != report.Completed || requests.Load() != int64(report.Requests) {
|
||||||
|
t.Fatalf("Run() = (%+v, %v); handler requests=%d", report, err, requests.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsUnboundedWorkload(t *testing.T) {
|
||||||
|
_, err := Run(context.Background(), Options{TargetURL: "http://127.0.0.1:8080", Concurrency: 1})
|
||||||
|
if !errors.Is(err, ErrInvalidOptions) {
|
||||||
|
t.Fatalf("Run(unbounded) error = %v, want ErrInvalidOptions", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user