feat: validate extraction load workloads
This commit is contained in:
parent
d04b7413eb
commit
97a515ddf0
22
README.md
22
README.md
@ -198,7 +198,8 @@ go run ./cmd/proxy-loadgen `
|
||||
```
|
||||
|
||||
使用 `-duration 30s -rate 5000` 可运行限速场景;省略 `-rate` 时固定数量 worker
|
||||
会饱和发送。`-method`、重复的 `-header` 与 `-body` 可组合用于 Distribution 的提取接口:
|
||||
会饱和发送。普通 HTTP 场景中,`-method`、重复的 `-header` 与 `-body` 可组合用于
|
||||
Distribution 的提取接口:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/proxy-loadgen `
|
||||
@ -208,9 +209,22 @@ go run ./cmd/proxy-loadgen `
|
||||
-requests 1000 -concurrency 32 -timeout 10s
|
||||
```
|
||||
|
||||
命令输出 JSON 报告,包含成功/失败分类、`tunnelsEstablished`、固定内存的连接握手
|
||||
延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它尚不包含 Extract 的专用数据准备/
|
||||
结果校验,也不构成 100,000 QPS 证明。
|
||||
`extract` 场景则自动构造 POST 请求、每请求独立的 `X-Request-ID` 与
|
||||
`Idempotency-Key`,并只校验响应的 `requestId`、数量和同响应内代理 ID 唯一性:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/proxy-loadgen `
|
||||
-scenario extract `
|
||||
-target http://CONTROLLER_HOST:8081/api/v1/proxies/extract `
|
||||
-header "X-API-Key: DISTRIBUTION_API_KEY" `
|
||||
-requests 1000 -concurrency 32 -timeout 10s `
|
||||
-extract-count 1 -extract-fulfillment partial
|
||||
```
|
||||
|
||||
命令输出 JSON 报告,包含成功/失败分类、`TunnelsEstablished`、
|
||||
`ExtractResponsesValidated`、`ExtractReturned`、`ExtractValidationFailures`、固定内存的
|
||||
连接握手延迟分位上界、吞吐和 Go 运行时内存/GC 快照。它不会输出提取响应中的地址或
|
||||
凭据,也不构成 100,000 QPS 证明。
|
||||
|
||||
## 关键配置与入口
|
||||
|
||||
|
||||
@ -30,8 +30,8 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
|
||||
flags.SetOutput(stderr)
|
||||
targetURL := flags.String("target", "", "HTTP or HTTPS target URL")
|
||||
proxyURL := flags.String("proxy", "", "optional HTTP or HTTPS forward proxy URL; connect requires HTTP")
|
||||
scenario := flags.String("scenario", string(loadgen.ScenarioHTTP), "workload scenario: http or connect")
|
||||
method := flags.String("method", http.MethodGet, "HTTP method")
|
||||
scenario := flags.String("scenario", string(loadgen.ScenarioHTTP), "workload scenario: http, connect, or extract")
|
||||
method := flags.String("method", "", "HTTP method; defaults to GET or POST for extract")
|
||||
body := flags.String("body", "", "UTF-8 request body")
|
||||
requests := flags.Int("requests", 0, "fixed request count; mutually exclusive with -duration")
|
||||
duration := flags.Duration("duration", 0, "time-boxed workload duration")
|
||||
@ -39,6 +39,8 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
|
||||
concurrency := flags.Int("concurrency", 64, "maximum concurrent requests")
|
||||
timeout := flags.Duration("timeout", 10*time.Second, "HTTP request or CONNECT establishment timeout")
|
||||
hold := flags.Duration("hold", 0, "CONNECT tunnel hold duration; required by -scenario connect")
|
||||
extractCount := flags.Int("extract-count", 1, "proxies requested by each extract scenario request")
|
||||
extractFulfillment := flags.String("extract-fulfillment", "partial", "extract fulfillment: partial or allOrNothing")
|
||||
var headers headerValues
|
||||
flags.Var(&headers, "header", "repeatable HTTP header in Name: Value form")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
@ -60,6 +62,7 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io.
|
||||
Scenario: loadgen.Scenario(*scenario), TargetURL: *targetURL, ProxyURL: *proxyURL, Method: *method, Headers: parsedHeaders, RequestBody: []byte(*body),
|
||||
Requests: *requests, Duration: *duration, Rate: *rate, Concurrency: *concurrency, RequestTimeout: *timeout,
|
||||
TunnelHold: *hold,
|
||||
ExtractCount: *extractCount, ExtractFulfillment: *extractFulfillment,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, loadgen.ErrInvalidOptions) {
|
||||
|
||||
@ -54,3 +54,19 @@ func TestExecutePassesCONNECTScenario(t *testing.T) {
|
||||
t.Fatalf("execute() = %d; options=%+v stderr=%q", code, received, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePassesExtractScenario(t *testing.T) {
|
||||
var received loadgen.Options
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := execute(context.Background(), []string{
|
||||
"-scenario", "extract", "-target", "http://controller.example:8081/api/v1/proxies/extract",
|
||||
"-requests", "2", "-concurrency", "1", "-timeout", "2s", "-extract-count", "3", "-extract-fulfillment", "allOrNothing",
|
||||
}, func(_ context.Context, options loadgen.Options) (loadgen.Report, error) {
|
||||
received = options
|
||||
return loadgen.Report{Requests: 2, Completed: 2, Succeeded: 2, Duration: time.Second}, nil
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 || received.Scenario != loadgen.ScenarioExtract || received.Method != "" || received.ExtractCount != 3 ||
|
||||
received.ExtractFulfillment != "allOrNothing" || stderr.Len() != 0 {
|
||||
t.Fatalf("execute() = %d; options=%+v stderr=%q", code, received, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@ -299,10 +299,10 @@ Profile 在启用 Routing 与 Upstream 的组合上才进入调度。
|
||||
|
||||
补充进度(2026-08-02):已新增 `proxy-loadgen` HTTP 与 CONNECT 长连接场景。固定请求数
|
||||
和固定时长两种模式均通过固定 worker 数与有界派发通道执行,可选 QPS 限速;报告使用固定大小
|
||||
延迟直方图,输出状态分类、CONNECT 建立数、吞吐和 Go 内存/GC 快照。CONNECT 以原始 TCP
|
||||
握手连接 HTTP Gateway,建连成功后按 `hold` 保持,且不透明读取隧道内容。通用
|
||||
`method/header/body` 参数可覆盖 Distribution 提取 HTTP 请求;Extract 的专用数据准备与结果
|
||||
校验、故障注入以及代表性集群报告仍未实现。
|
||||
延迟直方图,输出状态分类、CONNECT 建立数、Extract 校验数、吞吐和 Go 内存/GC 快照。CONNECT
|
||||
以原始 TCP 握手连接 HTTP Gateway,建连成功后按 `hold` 保持,且不透明读取隧道内容。`extract`
|
||||
场景会自动生成独立 Request/Idempotency 标识,校验返回数量与单响应 ID 唯一性,且不记录地址或
|
||||
凭据。故障注入以及代表性集群报告仍未实现。
|
||||
|
||||
## Task 12: Machine-readable Contracts
|
||||
|
||||
|
||||
@ -100,7 +100,8 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
|
||||
|
||||
以下已有设计、接口或部署位置,但尚无端到端生产实现:
|
||||
|
||||
1. `proxy-loadgen` 已提供有界 HTTP 与 HTTP Gateway CONNECT 长连接进程;`proxy-checker` 的 BASIC 任务进程已经完成,
|
||||
1. `proxy-loadgen` 已提供有界 HTTP、HTTP Gateway CONNECT 长连接和 Distribution Extract
|
||||
响应校验场景;`proxy-checker` 的 BASIC 任务进程已经完成,
|
||||
`proxy-controller` 已完成
|
||||
Admin/Distribution/Metrics 与 PostgreSQL/Redis 启动装配,`proxy-gateway` 已完成
|
||||
HTTP/Metrics 与控制面 Session 装配,但 Provider 和业务指标链未闭环。
|
||||
@ -119,7 +120,7 @@ Controller/Gateway 入口,完整 mTLS 运行时拓扑仍只有静态验证。
|
||||
原子归并、Controller Reducer 和 Observation 上报 RPC 已完成;EGRESS、TARGET
|
||||
生产任务调度与 REMOVE 编排仍待实现。
|
||||
8. Admin/Distribution 细粒度授权和审计查询;Distribution 分布式限流已完成。
|
||||
9. Extract 专用场景、真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
||||
9. 真实 Compose/Kubernetes 集成、故障演练和代表性集群负载测试。
|
||||
10. 将 reject/wait/direct 接入 Distribution 运行链,补齐 Sequential 持久化恢复、跨实例 CAS
|
||||
和 disabled candidate 语义。
|
||||
11. 补齐 Proxy Capacity 动态降容契约、Reservation 全生命周期观测;短 TTL
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
| 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/EGRESS/TARGET 共享任务运行态、`proxy-loadgen` 有界 HTTP/CONNECT 长连接场景均已实现 |
|
||||
| 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/EGRESS/TARGET 共享任务运行态、`proxy-loadgen` 有界 HTTP/CONNECT/Extract 校验场景均已实现 |
|
||||
| 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-004 | Controller 集中 Provider 获取与切换 | 1403-1580 | Redis Leader、动态 Provider Supervisor 与 Bootstrap 生产装配已完成;Admin disable/reload 驱动取消替换,多副本按权威 HMAC 指纹和 revision 栅栏收敛并拒绝旧配置换主;Routing 切换到 Drain 的编排待完成 |
|
||||
|
||||
@ -6,7 +6,10 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math/bits"
|
||||
@ -14,6 +17,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@ -22,13 +26,17 @@ import (
|
||||
|
||||
var ErrInvalidOptions = errors.New("invalid load generator options")
|
||||
|
||||
const maximumResponseDrainBytes = 64 << 10
|
||||
const (
|
||||
maximumResponseDrainBytes = 64 << 10
|
||||
maximumExtractResponseBytes = 256 << 10
|
||||
)
|
||||
|
||||
type Scenario string
|
||||
|
||||
const (
|
||||
ScenarioHTTP Scenario = "http"
|
||||
ScenarioConnect Scenario = "connect"
|
||||
ScenarioExtract Scenario = "extract"
|
||||
)
|
||||
|
||||
// Options bounds one HTTP workload. Requests selects a fixed-size run;
|
||||
@ -46,6 +54,8 @@ type Options struct {
|
||||
Concurrency int
|
||||
RequestTimeout time.Duration
|
||||
TunnelHold time.Duration
|
||||
ExtractCount int
|
||||
ExtractFulfillment string
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
@ -61,6 +71,9 @@ type Report struct {
|
||||
TimeoutErrors uint64
|
||||
RequestErrors uint64
|
||||
TunnelsEstablished uint64
|
||||
ExtractResponsesValidated uint64
|
||||
ExtractReturned uint64
|
||||
ExtractValidationFailures uint64
|
||||
Throughput float64
|
||||
Latency LatencyReport
|
||||
Runtime RuntimeReport
|
||||
@ -94,6 +107,9 @@ type counters struct {
|
||||
timeoutErrors atomic.Uint64
|
||||
requestErrors atomic.Uint64
|
||||
tunnelsEstablished atomic.Uint64
|
||||
extractResponsesValidated atomic.Uint64
|
||||
extractReturned atomic.Uint64
|
||||
extractValidationFailures atomic.Uint64
|
||||
latency latencyHistogram
|
||||
}
|
||||
|
||||
@ -111,13 +127,22 @@ func Run(ctx context.Context, options Options) (Report, error) {
|
||||
}
|
||||
stats := &counters{}
|
||||
var execute func(context.Context)
|
||||
if normalized.Scenario == ScenarioHTTP {
|
||||
if normalized.Scenario == ScenarioHTTP || normalized.Scenario == ScenarioExtract {
|
||||
client, clientErr := newHTTPClient(normalized)
|
||||
if clientErr != nil {
|
||||
return Report{}, clientErr
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
if normalized.Scenario == ScenarioHTTP {
|
||||
execute = func(requestCtx context.Context) { executeHTTP(requestCtx, client, normalized, stats) }
|
||||
} else {
|
||||
workloadID, workloadErr := newExtractWorkloadID()
|
||||
if workloadErr != nil {
|
||||
return Report{}, workloadErr
|
||||
}
|
||||
workload := &extractWorkload{id: workloadID, options: normalized, stats: stats}
|
||||
execute = func(requestCtx context.Context) { workload.execute(requestCtx, client) }
|
||||
}
|
||||
} else {
|
||||
execute = func(requestCtx context.Context) { executeCONNECT(requestCtx, normalized, stats) }
|
||||
}
|
||||
@ -144,8 +169,12 @@ func normalizeOptions(options Options) (Options, error) {
|
||||
normalized.Scenario = ScenarioHTTP
|
||||
}
|
||||
if normalized.Method == "" {
|
||||
if normalized.Scenario == ScenarioExtract {
|
||||
normalized.Method = http.MethodPost
|
||||
} else {
|
||||
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) ||
|
||||
@ -156,7 +185,7 @@ func normalizeOptions(options Options) (Options, error) {
|
||||
if err != nil || target.Scheme == "" || target.Host == "" || (target.Scheme != "http" && target.Scheme != "https") {
|
||||
return Options{}, ErrInvalidOptions
|
||||
}
|
||||
if normalized.Scenario != ScenarioHTTP && normalized.Scenario != ScenarioConnect {
|
||||
if normalized.Scenario != ScenarioHTTP && normalized.Scenario != ScenarioConnect && normalized.Scenario != ScenarioExtract {
|
||||
return Options{}, ErrInvalidOptions
|
||||
}
|
||||
if normalized.Scenario == ScenarioHTTP {
|
||||
@ -168,6 +197,20 @@ func normalizeOptions(options Options) (Options, error) {
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
if normalized.Scenario == ScenarioExtract {
|
||||
if normalized.ProxyURL != "" || normalized.TunnelHold != 0 || len(normalized.RequestBody) != 0 ||
|
||||
normalized.Method != http.MethodPost || normalized.ExtractCount <= 0 || normalized.ExtractCount > 64 ||
|
||||
(normalized.ExtractFulfillment != "" && strings.TrimSpace(normalized.ExtractFulfillment) != normalized.ExtractFulfillment) {
|
||||
return Options{}, ErrInvalidOptions
|
||||
}
|
||||
if normalized.ExtractFulfillment == "" {
|
||||
normalized.ExtractFulfillment = "partial"
|
||||
}
|
||||
if normalized.ExtractFulfillment != "partial" && normalized.ExtractFulfillment != "allOrNothing" {
|
||||
return Options{}, ErrInvalidOptions
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
if normalized.ProxyURL == "" || normalized.TunnelHold <= 0 || len(normalized.RequestBody) != 0 || normalized.Method != http.MethodGet {
|
||||
return Options{}, ErrInvalidOptions
|
||||
}
|
||||
@ -307,11 +350,12 @@ func runForDuration(ctx context.Context, options Options, execute func(context.C
|
||||
}
|
||||
}
|
||||
|
||||
type requestBuilder func(context.Context) (*http.Request, responseValidator, error)
|
||||
|
||||
type responseValidator func(*http.Response) error
|
||||
|
||||
func executeHTTP(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()
|
||||
executeHTTPRequest(ctx, client, options.RequestTimeout, stats, func(requestContext context.Context) (*http.Request, responseValidator, error) {
|
||||
request, err := http.NewRequestWithContext(
|
||||
requestContext,
|
||||
options.Method,
|
||||
@ -319,19 +363,29 @@ func executeHTTP(ctx context.Context, client *http.Client, options Options, stat
|
||||
bytes.NewReader(options.RequestBody),
|
||||
)
|
||||
if err != nil {
|
||||
stats.failed.Add(1)
|
||||
stats.requestErrors.Add(1)
|
||||
stats.completed.Add(1)
|
||||
stats.latency.Record(time.Since(started))
|
||||
return
|
||||
return nil, nil, err
|
||||
}
|
||||
request.Header = options.Headers.Clone()
|
||||
return request, discardResponse, nil
|
||||
})
|
||||
}
|
||||
|
||||
func executeHTTPRequest(ctx context.Context, client *http.Client, timeout time.Duration, stats *counters, build requestBuilder) {
|
||||
stats.requests.Add(1)
|
||||
started := time.Now()
|
||||
requestContext, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
request, validate, err := build(requestContext)
|
||||
if err != nil {
|
||||
recordRequestError(started, stats, false)
|
||||
return
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
latency := time.Since(started)
|
||||
stats.latency.Record(latency)
|
||||
stats.completed.Add(1)
|
||||
if err != nil {
|
||||
stats.failed.Add(1)
|
||||
stats.completed.Add(1)
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(requestContext.Err(), context.DeadlineExceeded) {
|
||||
stats.timeoutErrors.Add(1)
|
||||
} else {
|
||||
@ -339,10 +393,18 @@ func executeHTTP(ctx context.Context, client *http.Client, options Options, stat
|
||||
}
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maximumResponseDrainBytes))
|
||||
_ = response.Body.Close()
|
||||
defer response.Body.Close()
|
||||
switch {
|
||||
case response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices:
|
||||
if validate == nil {
|
||||
validate = discardResponse
|
||||
}
|
||||
if err := validate(response); err != nil {
|
||||
stats.failed.Add(1)
|
||||
stats.requestErrors.Add(1)
|
||||
stats.completed.Add(1)
|
||||
return
|
||||
}
|
||||
stats.succeeded.Add(1)
|
||||
case response.StatusCode >= http.StatusInternalServerError:
|
||||
stats.failed.Add(1)
|
||||
@ -354,6 +416,114 @@ func executeHTTP(ctx context.Context, client *http.Client, options Options, stat
|
||||
stats.failed.Add(1)
|
||||
stats.requestErrors.Add(1)
|
||||
}
|
||||
stats.completed.Add(1)
|
||||
}
|
||||
|
||||
func discardResponse(response *http.Response) error {
|
||||
if response == nil || response.Body == nil {
|
||||
return ErrInvalidOptions
|
||||
}
|
||||
_, err := io.Copy(io.Discard, io.LimitReader(response.Body, maximumResponseDrainBytes))
|
||||
return err
|
||||
}
|
||||
|
||||
type extractWorkload struct {
|
||||
id string
|
||||
options Options
|
||||
stats *counters
|
||||
next atomic.Uint64
|
||||
}
|
||||
|
||||
func newExtractWorkloadID() (string, error) {
|
||||
var value [12]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "loadgen-" + hex.EncodeToString(value[:]), nil
|
||||
}
|
||||
|
||||
func (workload *extractWorkload) execute(ctx context.Context, client *http.Client) {
|
||||
sequence := workload.next.Add(1)
|
||||
requestID := workload.id + "-" + strconv.FormatUint(sequence, 10)
|
||||
executeHTTPRequest(ctx, client, workload.options.RequestTimeout, workload.stats, func(requestContext context.Context) (*http.Request, responseValidator, error) {
|
||||
body, err := json.Marshal(struct {
|
||||
Count int `json:"count"`
|
||||
Fulfillment string `json:"fulfillment"`
|
||||
}{Count: workload.options.ExtractCount, Fulfillment: workload.options.ExtractFulfillment})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(requestContext, http.MethodPost, workload.options.TargetURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
request.Header = workload.options.Headers.Clone()
|
||||
if request.Header == nil {
|
||||
request.Header = make(http.Header)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Request-ID", requestID)
|
||||
request.Header.Set("Idempotency-Key", requestID)
|
||||
return request, func(response *http.Response) error {
|
||||
return validateExtractResponse(response, requestID, workload.options, workload.stats)
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func validateExtractResponse(response *http.Response, requestID string, options Options, stats *counters) error {
|
||||
if response == nil || response.Body == nil {
|
||||
stats.extractValidationFailures.Add(1)
|
||||
return ErrInvalidOptions
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maximumExtractResponseBytes+1))
|
||||
if err != nil || len(body) > maximumExtractResponseBytes {
|
||||
stats.extractValidationFailures.Add(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrInvalidOptions
|
||||
}
|
||||
var payload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Requested int `json:"requested"`
|
||||
Returned int `json:"returned"`
|
||||
Proxies json.RawMessage `json:"proxies"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload.RequestID != requestID || payload.Requested != options.ExtractCount ||
|
||||
len(payload.Proxies) == 0 || bytes.Equal(bytes.TrimSpace(payload.Proxies), []byte("null")) ||
|
||||
(options.ExtractFulfillment == "allOrNothing" && payload.Returned != options.ExtractCount) {
|
||||
stats.extractValidationFailures.Add(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrInvalidOptions
|
||||
}
|
||||
var proxies []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload.Proxies, &proxies); err != nil || payload.Returned < 0 || payload.Returned > options.ExtractCount ||
|
||||
payload.Returned != len(proxies) {
|
||||
stats.extractValidationFailures.Add(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ErrInvalidOptions
|
||||
}
|
||||
ids := make(map[string]struct{}, len(proxies))
|
||||
for _, proxy := range proxies {
|
||||
if proxy.ID == "" {
|
||||
stats.extractValidationFailures.Add(1)
|
||||
return ErrInvalidOptions
|
||||
}
|
||||
if _, duplicate := ids[proxy.ID]; duplicate {
|
||||
stats.extractValidationFailures.Add(1)
|
||||
return ErrInvalidOptions
|
||||
}
|
||||
ids[proxy.ID] = struct{}{}
|
||||
}
|
||||
stats.extractResponsesValidated.Add(1)
|
||||
stats.extractReturned.Add(uint64(payload.Returned))
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeCONNECT(ctx context.Context, options Options, stats *counters) {
|
||||
@ -484,7 +654,8 @@ func report(started time.Time, scenario Scenario, stats *counters) Report {
|
||||
Succeeded: stats.succeeded.Load(), Failed: stats.failed.Load(), Status4xx: stats.status4xx.Load(),
|
||||
Status5xx: stats.status5xx.Load(), TimeoutErrors: stats.timeoutErrors.Load(), RequestErrors: stats.requestErrors.Load(),
|
||||
TunnelsEstablished: stats.tunnelsEstablished.Load(),
|
||||
Latency: stats.latency.Report(completed),
|
||||
ExtractResponsesValidated: stats.extractResponsesValidated.Load(), ExtractReturned: stats.extractReturned.Load(),
|
||||
ExtractValidationFailures: stats.extractValidationFailures.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 {
|
||||
|
||||
@ -3,6 +3,7 @@ package loadgen
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -136,6 +137,74 @@ func TestRunClassifiesCONNECTProxyRejection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecutesAndValidatesExtractWorkload(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("Content-Type") != "application/json" ||
|
||||
request.Header.Get("X-Request-ID") == "" || request.Header.Get("Idempotency-Key") == "" {
|
||||
t.Errorf("extract request = method=%s content-type=%q request-id=%q idempotency=%q",
|
||||
request.Method, request.Header.Get("Content-Type"), request.Header.Get("X-Request-ID"), request.Header.Get("Idempotency-Key"))
|
||||
}
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
Fulfillment string `json:"fulfillment"`
|
||||
}
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil || body.Count != 2 || body.Fulfillment != "partial" {
|
||||
t.Errorf("extract body = %+v, error = %v", body, err)
|
||||
}
|
||||
requests.Add(1)
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(response, `{"requestId":%q,"requested":2,"returned":2,"proxies":[{"id":"proxy-a"},{"id":"proxy-b"}]}`,
|
||||
request.Header.Get("X-Request-ID"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
report, err := Run(context.Background(), Options{
|
||||
Scenario: ScenarioExtract, TargetURL: server.URL, Requests: 2, Concurrency: 1, RequestTimeout: time.Second,
|
||||
ExtractCount: 2, ExtractFulfillment: "partial",
|
||||
})
|
||||
if err != nil || report.Requests != 2 || report.Completed != 2 || report.Succeeded != 2 || report.Failed != 0 ||
|
||||
report.ExtractResponsesValidated != 2 || report.ExtractReturned != 4 || report.ExtractValidationFailures != 0 || requests.Load() != 2 {
|
||||
t.Fatalf("Run() = (%+v, %v); extract requests=%d", report, err, requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsInvalidExtractResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(response, `{"requestId":%q,"requested":2,"returned":2,"proxies":[{"id":"proxy-a"}]}`,
|
||||
request.Header.Get("X-Request-ID"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
report, err := Run(context.Background(), Options{
|
||||
Scenario: ScenarioExtract, TargetURL: server.URL, Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
|
||||
ExtractCount: 2, ExtractFulfillment: "partial",
|
||||
})
|
||||
if err != nil || report.Succeeded != 0 || report.Failed != 1 || report.ExtractResponsesValidated != 0 ||
|
||||
report.ExtractValidationFailures != 1 || report.ExtractReturned != 0 {
|
||||
t.Fatalf("Run() = (%+v, %v)", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsDuplicateProxyIDsInExtractResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(response, `{"requestId":%q,"requested":2,"returned":2,"proxies":[{"id":"proxy-a"},{"id":"proxy-a"}]}`,
|
||||
request.Header.Get("X-Request-ID"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
report, err := Run(context.Background(), Options{
|
||||
Scenario: ScenarioExtract, TargetURL: server.URL, Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
|
||||
ExtractCount: 2, ExtractFulfillment: "partial",
|
||||
})
|
||||
if err != nil || report.Succeeded != 0 || report.Failed != 1 || report.ExtractResponsesValidated != 0 ||
|
||||
report.ExtractValidationFailures != 1 {
|
||||
t.Fatalf("Run() = (%+v, %v)", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsCONNECTWithoutProxyOrHold(t *testing.T) {
|
||||
_, err := Run(context.Background(), Options{
|
||||
Scenario: ScenarioConnect, TargetURL: "https://target.example/health", Requests: 1, Concurrency: 1, RequestTimeout: time.Second,
|
||||
@ -145,6 +214,16 @@ func TestRunRejectsCONNECTWithoutProxyOrHold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsInvalidExtractOptions(t *testing.T) {
|
||||
_, err := Run(context.Background(), Options{
|
||||
Scenario: ScenarioExtract, TargetURL: "http://127.0.0.1:8081/api/v1/proxies/extract", Requests: 1, Concurrency: 1,
|
||||
RequestTimeout: time.Second, ExtractCount: 0,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidOptions) {
|
||||
t.Fatalf("Run(invalid extract options) error = %v, want ErrInvalidOptions", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsUnboundedWorkload(t *testing.T) {
|
||||
_, err := Run(context.Background(), Options{TargetURL: "http://127.0.0.1:8080", Concurrency: 1})
|
||||
if !errors.Is(err, ErrInvalidOptions) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user