feat: add bounded admission load tool
This commit is contained in:
@@ -85,3 +85,4 @@
|
||||
- HTTP成功升级在Hijack完成时记录握手,WS处理器存续计入独立连接指标;保持1xx临时响应、Flush、Hijack与原panic行为。DB池取本地Stats,抓取不查询业务库;后台批次success不是业务任务成功量。观测丢样或失败不得改变权威事务及恢复决定。
|
||||
- 进程指标、固定状态采样、格式验证、告警规则和标准回归证据见docs/reports/phase-6-observability.md及docs/reports/phase-6-business-observability.md。仍须补受理/授权/完成与流程推进延迟、持久吞吐/积压的全局聚合、重试/核查/恢复量、发送队列、实际采集告警及负载开销,不能把进程观测通过写成P6已完成。普通清理仍须保护有效幂等、未确认事件、未知结果和未终结流程。
|
||||
- 凭据轮换保留credential_id及订阅/Worker授权绑定,在同一事务替换摘要、固定到期时间、幂等回执和管理审计;新令牌必须重新建立会话,认证路径每次重新核对凭据状态。撤销必须让认证失效并保留原任务、订阅、交付、会话和审计证据,允许在应用或命名空间停用后执行;重复应用同一固定操作幂等返回。管理CLI先把固定操作文件写成0600,再提交;提交不明时保留文件并用原文件重放,不能重新生成秘密。日志、审计摘要、指标和业务表不得保存秘密原文或摘要。不要把轮换同一凭据ID解释成历史令牌仍有效,也不得级联删除订阅、任务或事件。
|
||||
- `sdk/go/cmd/orbit-load` 只用于隔离环境受理测量:每项使用稳定独立幂等键,固定请求形状和并发上限,按请求超时,不自动重试,不启动Worker或执行外部副作用;输出延迟百分位与有限错误分类,失败必须以非零状态返回。其结果不能替代真实Worker、流程推进、数据库拓扑或生产容量验收。
|
||||
|
||||
@@ -77,6 +77,8 @@ make integration
|
||||
|
||||
`make integration` 要求 Docker、OpenSSL 等标准命令,启动独立的临时 MySQL 容器,使用随机凭据和回环随机端口;完成后删除该测试容器。不会连接已有业务数据库。可通过 `GO`、`DOCKER` 和 `ORBIT_TEST_MYSQL_IMAGE` 指定工具/测试镜像。
|
||||
|
||||
受理层负载采样工具见 [有界受理负载工具](docs/api/load.md),只允许在隔离命名空间或一次性数据库中运行;它不自动重试、不启动 Worker,也不提供生产容量承诺。
|
||||
|
||||
修改已应用迁移时会触发校验和拒绝;新增结构使用新的前向迁移。初始结构尚未部署生产。
|
||||
|
||||
## 文档入口
|
||||
|
||||
@@ -62,6 +62,7 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
||||
2. 已实现[基础运行观测](api/observability.md)及[有界业务状态与事件采样](reports/phase-6-business-observability.md):HTTP/WS、数据库连接池、后台批次、固定任务/交付/流程/Inbox状态计数、最近事件类别、终态耗时样本、固定事件阶段耗时、最早年龄、截断/新鲜度指标和可离线验证的告警规则;独立采集监听默认关闭,固定指标目录拒绝动态标签;发布前只读结构核验由[`orbit-admin verify`](api/operations.md)提供。仍需端到端外部效果延迟、全局持久吞吐、Inbox从事件产生到推进完成的精确阶段延迟、全局发送队列、多节点聚合及生产采集/通知验证。
|
||||
3. 已补齐受保护管理环境中的固定凭据操作文件、轮换、撤销、幂等回执和脱敏管理审计;轮换保留授权绑定并要求新令牌重新建立会话,撤销允许在停用范围内执行且不改写历史记录。仍需补齐提交不明故障切点、多节点安全重绑演练、凭据最小权限配置核对和生产密钥托管/审计演练。
|
||||
4. 已补[`orbit-admin retention inventory`](api/retention.md)只读盘点和[阶段报告](reports/phase-6-retention-inventory.md),能区分过期幂等、确定终态、未知效果、未终结流程、已确认/未确认交付、已消费/未消费Inbox、事件和审计规模;它不执行归档或删除。后续仍需评审各类保留周期、归档格式、受保护记录核对,再执行备份恢复、兼容升级和六类容量负载。未确认交付、有效幂等身份、当前/历史执行证据和未终结流程不能被普通清理破坏。生产HA及RPO/RTO继续按实际拓扑验证,不缩减为本机多进程检查。
|
||||
5. 已补[`orbit-load`](api/load.md)有界受理负载工具:固定请求形状、并发上限、每请求超时、无自动重试、延迟百分位和有限错误分类;已完成一次隔离 MySQL + HTTP 小样本实测,证据见[负载报告](reports/phase-6-load.md)。标准 Go 1.27.1 隔离回归仍需在工具链恢复后复跑;该工具只提供受理层测量,真实 Worker/流程/数据库拓扑容量仍需独立报告。
|
||||
|
||||
### P3 功能范围核对
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
| 凭据校验、初始化、固定操作文件轮换/撤销与回执重放 CLI | P1/P6 已实现;不提供公网管理路由,测试专用提交不明切点已回归 | [鉴权](authentication.md)、[凭据报告](../reports/phase-6-credentials.md) |
|
||||
| `orbit-admin verify` 发布前结构核验 | 已实现只读迁移、零外键和 000026–000028 固定索引检查 | [运维核验](operations.md) |
|
||||
| `orbit-admin retention inventory` 保留清单 | 已实现带 UTC 截止时间的只读盘点;不执行归档、删除或权限变更 | [保留清单](retention.md) |
|
||||
| `sdk/go/cmd/orbit-load` 有界受理负载工具 | 已实现固定并发、无自动重试、延迟百分位和错误分类;只用于隔离环境 | [负载工具](load.md) |
|
||||
| `/v1/worker/connect` | P2 已实现并通过阶段验收 | [Worker 契约](worker.md)、[Schema](worker-schema.md) |
|
||||
| 任务相对延迟 `delay_ms` | 已实现数据库计时、原键恢复与实际执行例子 | [任务接口](tasks.md)、[延迟示例](../examples/delayed-task.md) |
|
||||
| 任务冻结策略与 Worker `retryable` | P3 安全重试已实现 | [安全重试](retries.md) |
|
||||
|
||||
35
docs/api/load.md
Normal file
35
docs/api/load.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# 有界受理负载工具
|
||||
|
||||
`orbit-load` 是独立 Go SDK module 中的受理负载工具,用于在隔离环境测量 HTTP 受理请求的延迟分布。它只提交带独立幂等键的任务,不启动 Worker、不执行业务副作用、不自动重试,也不把测量结果解释为生产吞吐或租户容量保证。
|
||||
|
||||
## 运行
|
||||
|
||||
先为专用测试命名空间准备凭据,再运行:
|
||||
|
||||
```sh
|
||||
cd sdk/go
|
||||
ORBIT_URL='http://127.0.0.1:8080' \
|
||||
ORBIT_CREDENTIALS_FILE='/private/orbit-load.credentials.json' \
|
||||
go run ./cmd/orbit-load \
|
||||
-requests 1000 \
|
||||
-concurrency 32 \
|
||||
-timeout 10s
|
||||
```
|
||||
|
||||
也可以直接传 `-url`、`-token` 和 `-queue`。命令拒绝非 32 位小写十六进制队列 ID,默认上限是 100000 次请求和 1024 个并发请求;并发数超过请求数时自动降到请求数。每次请求的超时独立计算,超时或 HTTP 错误不会再次提交。
|
||||
|
||||
每个任务使用形如 `orbit-load-{run_id}-{sequence}` 的幂等键,输入只包含本次运行标识和序号,处理器默认为 `load.noop`,任务尝试预算固定为 1。命令不会删除生成的任务;测试必须使用一次性数据库或专用命名空间,结束后按保留策略处理,不能直接对生产命名空间运行。
|
||||
|
||||
## 输出
|
||||
|
||||
标准输出是一行 JSON:
|
||||
|
||||
```json
|
||||
{"started_at":"2026-09-19T00:00:00Z","finished_at":"2026-09-19T00:00:02Z","requests":100,"concurrency":8,"success":100,"errors":0,"p50_ms":11.2,"p95_ms":24.7,"p99_ms":31.4,"max_ms":35.1}
|
||||
```
|
||||
|
||||
`p50_ms`、`p95_ms`、`p99_ms` 和 `max_ms` 统计所有请求(包括失败请求)的端到端 HTTP 交换耗时,百分位采用最近秩;`error_codes` 只保留有限错误分类,例如 `TIMEOUT`、`TRANSPORT`、`HTTP_409` 或服务端稳定错误码,不输出令牌、请求体或内部错误文本。存在失败请求时仍输出报告,并以非零状态退出。
|
||||
|
||||
## 解释边界
|
||||
|
||||
这项工具固定的是客户端请求数、并发和消息形状,不能替代以下验证:真实 Worker 执行吞吐、定时触发、流程推进、数据库锁等待、复制延迟、磁盘与网络 I/O、故障恢复、跨主机拓扑或长时间积压。正式容量报告必须同时记录硬件、MySQL 拓扑、连接池、数据量、任务时长、结果大小、故障注入和积压清空时间,并将本工具结果与其他阶段指标分开。
|
||||
28
docs/reports/phase-6-load.md
Normal file
28
docs/reports/phase-6-load.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# P6:受理层有界负载工具
|
||||
|
||||
验证日期:2026-09-19。本批新增 `sdk/go/cmd/orbit-load`,用于隔离环境测量任务受理 HTTP 交换耗时。工具固定请求并发上限和每请求超时,使用独立幂等键,不自动重试、不启动 Worker、不执行外部副作用;失败仍输出报告并以非零状态退出。它不构成生产容量、可用性或百万租户承诺。
|
||||
|
||||
## 实现边界
|
||||
|
||||
- 请求数限制为 1–100000,并发限制为 1–1024;每次请求最多一个 HTTP 提交。
|
||||
- 默认处理器为 `load.noop`,输入只包含随机运行标识和序号,任务尝试预算为 1,避免把测试逻辑误当业务处理器。
|
||||
- 输出成功数、错误分类、p50/p95/p99/最大耗时;百分位采用最近秩,错误分类不包含令牌、请求体或内部错误文本。
|
||||
- 工具不清理生成任务,运行前必须使用一次性数据库或专用命名空间;正式容量测试还需配套 Worker、流程、数据库锁等待、积压恢复和拓扑故障测量。
|
||||
|
||||
## 隔离实测
|
||||
|
||||
环境:macOS arm64 主机,Docker MySQL 8.4,已有 Orbit 服务二进制,负载命令使用 Go 1.25 兼容 modfile 交叉编译为 darwin/arm64;数据库和任务容器在测试结束后销毁。该次只验证工具与受理链路,不能替代项目要求的 Go 1.27.1 标准全量回归。
|
||||
|
||||
命令参数:`requests=24`、`concurrency=4`、`timeout=5s`,独立命名空间和队列,未启动 Worker。
|
||||
|
||||
实际输出:
|
||||
|
||||
```json
|
||||
{"started_at":"2026-09-19T15:39:43.724491Z","finished_at":"2026-09-19T15:39:43.953625Z","requests":24,"concurrency":4,"success":24,"errors":0,"p50_ms":32.179459,"p95_ms":61.578333,"p99_ms":70.748416,"max_ms":70.748416}
|
||||
```
|
||||
|
||||
结果证明 24 次独立 HTTP 受理均成功且没有错误分类;延迟数值只属于本次主机、连接、数据量和请求形状,不能外推到生产。Go 1.27.1 工具链恢复后,标准 `scripts/test-mysql.sh` 会再次执行相同负载样本并把输出纳入全量日志。
|
||||
|
||||
## 未完成的生产验收
|
||||
|
||||
仍需在固定硬件和目标拓扑中补充短/中/长任务混合、Worker 连接与重连、定时集中触发、流程推进、慢消费者、长期积压、数据库锁等待、复制延迟、故障切换、备份恢复及容量余量测试;这些结果必须与本工具的受理层样本分开记录。
|
||||
@@ -43,10 +43,12 @@ ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" "$TASK_GO" test -race -count=1 -
|
||||
ORBIT_TEST_SERVER_BINARY="$TASK_TEST_DIR/orbit" "$TASK_GO" test -race -count=1 -run '^TestIntegrationWorkflowRuntimeProcess$' -v ./internal/storage/mysql
|
||||
"$TASK_GO" build -o "$TASK_TEST_DIR/orbit-migrate" ./cmd/orbit-migrate
|
||||
"$TASK_GO" build -o "$TASK_TEST_DIR/orbit-admin" ./cmd/orbit-admin
|
||||
(cd sdk/go && GOWORK=off "$TASK_GO" build -o "$TASK_TEST_DIR/orbit-load" ./cmd/orbit-load)
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-migrate" status
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" verify
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" retention inventory --before 2000-01-01T00:00:00Z
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-example --namespace default --environment test --queue files --output "$TASK_TEST_DIR/sdk.credentials.json"
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-load --namespace default --environment test --queue load --output "$TASK_TEST_DIR/load.credentials.json"
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" ORBIT_HTTP_ADDR=127.0.0.1:0 "$TASK_TEST_DIR/orbit" > "$TASK_TEST_DIR/server.log" 2>&1 &
|
||||
TASK_SERVER_PID=$!
|
||||
TASK_HTTP_PORT=
|
||||
@@ -58,6 +60,7 @@ for task_attempt in $(seq 1 20); do
|
||||
done
|
||||
if [ -z "$TASK_HTTP_PORT" ]; then printf '%s\n' 'Bootstrap HTTP server did not start.' >&2; exit 1; fi
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" "$TASK_GO" run ./examples/health)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/load.credentials.json" "$TASK_TEST_DIR/orbit-load" -requests 24 -concurrency 4 -timeout 5s)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/sdk.credentials.json" "$TASK_GO" run ./examples/tasks)
|
||||
(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/sdk.credentials.json" "$TASK_GO" run ./examples/task-query)
|
||||
ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-worker --namespace default --environment test --queue worker-examples --worker-access --schedule-access --event-access --output "$TASK_TEST_DIR/worker.credentials.json"
|
||||
|
||||
300
sdk/go/cmd/orbit-load/main.go
Normal file
300
sdk/go/cmd/orbit-load/main.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Command orbit-load runs a bounded, non-retrying task admission sample.
|
||||
// It is a measurement tool for an isolated environment, not a production
|
||||
// scheduler and not a capacity promise.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
type credentials struct {
|
||||
Token string `json:"token"`
|
||||
QueueID string `json:"queue_id"`
|
||||
}
|
||||
|
||||
type sampleConfig struct {
|
||||
URL string
|
||||
Token string
|
||||
QueueID string
|
||||
Requests int
|
||||
Concurrency int
|
||||
Timeout time.Duration
|
||||
Handler string
|
||||
HandlerVersion string
|
||||
PayloadVersion string
|
||||
}
|
||||
|
||||
type sampleResult struct {
|
||||
duration time.Duration
|
||||
code string
|
||||
}
|
||||
|
||||
type loadReport struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
Requests int `json:"requests"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
Success int `json:"success"`
|
||||
Errors int `json:"errors"`
|
||||
P50MS float64 `json:"p50_ms"`
|
||||
P95MS float64 `json:"p95_ms"`
|
||||
P99MS float64 `json:"p99_ms"`
|
||||
MaxMS float64 `json:"max_ms"`
|
||||
ErrorCodes map[string]int `json:"error_codes,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg, err := parseConfig(os.Args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := orbit.NewClient(cfg.URL, orbit.Options{
|
||||
HTTPClient: &http.Client{Timeout: cfg.Timeout},
|
||||
Token: cfg.Token,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runID, err := newRunID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
started := time.Now().UTC()
|
||||
results := runSamples(client, cfg, runID)
|
||||
finished := time.Now().UTC()
|
||||
report := makeReport(cfg, started, finished, results)
|
||||
if err := json.NewEncoder(os.Stdout).Encode(report); err != nil {
|
||||
return err
|
||||
}
|
||||
if report.Errors != 0 {
|
||||
return errors.New("load sample had failed requests; inspect error_codes and rerun in an isolated environment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConfig(args []string) (sampleConfig, error) {
|
||||
flags := flag.NewFlagSet("orbit-load", flag.ContinueOnError)
|
||||
flags.SetOutput(os.Stderr)
|
||||
cfg := sampleConfig{
|
||||
URL: os.Getenv("ORBIT_URL"),
|
||||
Token: os.Getenv("ORBIT_TOKEN"),
|
||||
QueueID: os.Getenv("ORBIT_QUEUE_ID"),
|
||||
Requests: 100,
|
||||
Concurrency: 8,
|
||||
Timeout: 10 * time.Second,
|
||||
Handler: "load.noop",
|
||||
HandlerVersion: "v1",
|
||||
PayloadVersion: "v1",
|
||||
}
|
||||
credentialsPath := os.Getenv("ORBIT_CREDENTIALS_FILE")
|
||||
flags.StringVar(&cfg.URL, "url", cfg.URL, "Orbit HTTP URL")
|
||||
flags.StringVar(&cfg.Token, "token", cfg.Token, "Bearer token; prefer ORBIT_CREDENTIALS_FILE")
|
||||
flags.StringVar(&cfg.QueueID, "queue", cfg.QueueID, "target queue ID")
|
||||
flags.StringVar(&credentialsPath, "credentials", credentialsPath, "private bootstrap credentials JSON file")
|
||||
flags.IntVar(&cfg.Requests, "requests", cfg.Requests, "number of independent admissions, 1..100000")
|
||||
flags.IntVar(&cfg.Concurrency, "concurrency", cfg.Concurrency, "maximum concurrent HTTP requests, 1..1024")
|
||||
flags.DurationVar(&cfg.Timeout, "timeout", cfg.Timeout, "per-request HTTP timeout")
|
||||
flags.StringVar(&cfg.Handler, "handler", cfg.Handler, "handler name frozen in each task")
|
||||
flags.StringVar(&cfg.HandlerVersion, "handler-version", cfg.HandlerVersion, "handler version frozen in each task")
|
||||
flags.StringVar(&cfg.PayloadVersion, "payload-version", cfg.PayloadVersion, "payload contract version frozen in each task")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return sampleConfig{}, err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return sampleConfig{}, errors.New("orbit-load does not accept positional arguments")
|
||||
}
|
||||
if credentialsPath != "" {
|
||||
file, err := os.ReadFile(credentialsPath)
|
||||
if err != nil {
|
||||
return sampleConfig{}, fmt.Errorf("read credentials: %w", err)
|
||||
}
|
||||
if len(file) > 16<<10 {
|
||||
return sampleConfig{}, errors.New("credentials file exceeds 16 KiB")
|
||||
}
|
||||
var saved credentials
|
||||
if err := json.Unmarshal(file, &saved); err != nil {
|
||||
return sampleConfig{}, errors.New("invalid credentials file")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
cfg.Token = saved.Token
|
||||
}
|
||||
if cfg.QueueID == "" {
|
||||
cfg.QueueID = saved.QueueID
|
||||
}
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return sampleConfig{}, errors.New("-url or ORBIT_URL is required")
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return sampleConfig{}, errors.New("-token, ORBIT_TOKEN or ORBIT_CREDENTIALS_FILE is required")
|
||||
}
|
||||
if !validID(cfg.QueueID) {
|
||||
return sampleConfig{}, errors.New("-queue or ORBIT_QUEUE_ID must be a 32-character lowercase hexadecimal ID")
|
||||
}
|
||||
if cfg.Requests < 1 || cfg.Requests > 100000 {
|
||||
return sampleConfig{}, errors.New("-requests must be between 1 and 100000")
|
||||
}
|
||||
if cfg.Concurrency < 1 || cfg.Concurrency > 1024 {
|
||||
return sampleConfig{}, errors.New("-concurrency must be between 1 and 1024")
|
||||
}
|
||||
if cfg.Timeout <= 0 || cfg.Timeout > time.Hour {
|
||||
return sampleConfig{}, errors.New("-timeout must be greater than zero and no more than one hour")
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"handler": cfg.Handler,
|
||||
"handler-version": cfg.HandlerVersion,
|
||||
"payload-version": cfg.PayloadVersion,
|
||||
} {
|
||||
if strings.TrimSpace(value) == "" || strings.ContainsAny(value, "\r\n") {
|
||||
return sampleConfig{}, fmt.Errorf("-%s must be a non-empty single line", name)
|
||||
}
|
||||
}
|
||||
if cfg.Concurrency > cfg.Requests {
|
||||
cfg.Concurrency = cfg.Requests
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func runSamples(client *orbit.Client, cfg sampleConfig, runID string) []sampleResult {
|
||||
jobs := make(chan int)
|
||||
results := make(chan sampleResult, cfg.Requests)
|
||||
var wait sync.WaitGroup
|
||||
for worker := 0; worker < cfg.Concurrency; worker++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
for sequence := range jobs {
|
||||
start := time.Now()
|
||||
requestCtx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
|
||||
payload, _ := json.Marshal(struct {
|
||||
RunID string `json:"run_id"`
|
||||
Sequence int `json:"sequence"`
|
||||
}{RunID: runID, Sequence: sequence})
|
||||
_, err := client.SubmitTask(requestCtx, fmt.Sprintf("orbit-load-%s-%06d", runID, sequence), orbit.SubmitTaskRequest{
|
||||
QueueID: cfg.QueueID,
|
||||
Handler: cfg.Handler,
|
||||
HandlerVersion: cfg.HandlerVersion,
|
||||
PayloadVersion: cfg.PayloadVersion,
|
||||
Payload: payload,
|
||||
TaskTimeoutMS: 60 * 60 * 1000,
|
||||
RecoverySafety: orbit.RecoveryReconcileOnly,
|
||||
Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 10 * 1000},
|
||||
})
|
||||
cancel()
|
||||
code := ""
|
||||
if err != nil {
|
||||
code = errorCode(err)
|
||||
}
|
||||
results <- sampleResult{duration: time.Since(start), code: code}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for sequence := 0; sequence < cfg.Requests; sequence++ {
|
||||
jobs <- sequence
|
||||
}
|
||||
close(jobs)
|
||||
wait.Wait()
|
||||
close(results)
|
||||
out := make([]sampleResult, 0, cfg.Requests)
|
||||
for result := range results {
|
||||
out = append(out, result)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func makeReport(cfg sampleConfig, started, finished time.Time, results []sampleResult) loadReport {
|
||||
durations := make([]time.Duration, 0, len(results))
|
||||
errorCodes := make(map[string]int)
|
||||
success := 0
|
||||
for _, result := range results {
|
||||
durations = append(durations, result.duration)
|
||||
if result.code == "" {
|
||||
success++
|
||||
} else {
|
||||
errorCodes[result.code]++
|
||||
}
|
||||
}
|
||||
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
|
||||
return loadReport{
|
||||
StartedAt: started, FinishedAt: finished,
|
||||
Requests: cfg.Requests, Concurrency: cfg.Concurrency,
|
||||
Success: success, Errors: len(results) - success,
|
||||
P50MS: percentileMS(durations, 0.50), P95MS: percentileMS(durations, 0.95),
|
||||
P99MS: percentileMS(durations, 0.99), MaxMS: percentileMS(durations, 1),
|
||||
ErrorCodes: errorCodes,
|
||||
}
|
||||
}
|
||||
|
||||
func percentileMS(values []time.Duration, fraction float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := int(math.Ceil(float64(len(values))*fraction)) - 1
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
if index >= len(values) {
|
||||
index = len(values) - 1
|
||||
}
|
||||
return float64(values[index]) / float64(time.Millisecond)
|
||||
}
|
||||
|
||||
func errorCode(err error) string {
|
||||
var apiErr *orbit.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr.Code != "" {
|
||||
return apiErr.Code
|
||||
}
|
||||
return fmt.Sprintf("HTTP_%d", apiErr.StatusCode)
|
||||
}
|
||||
var httpErr *orbit.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
return fmt.Sprintf("HTTP_%d", httpErr.StatusCode)
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "TIMEOUT"
|
||||
}
|
||||
return "TRANSPORT"
|
||||
}
|
||||
|
||||
func newRunID() (string, error) {
|
||||
var bytes [16]byte
|
||||
if _, err := rand.Read(bytes[:]); err != nil {
|
||||
return "", fmt.Errorf("generate load run id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes[:]), nil
|
||||
}
|
||||
|
||||
func validID(value string) bool {
|
||||
if len(value) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, ch := range value {
|
||||
if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
79
sdk/go/cmd/orbit-load/main_test.go
Normal file
79
sdk/go/cmd/orbit-load/main_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
orbit "git.yuntuo.hk/k2safe/orbit/sdk/go"
|
||||
)
|
||||
|
||||
func TestPercentileMSUsesNearestRankAndEmptyIsZero(t *testing.T) {
|
||||
values := []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 30 * time.Millisecond, 40 * time.Millisecond}
|
||||
if got := percentileMS(values, 0.50); got != 20 {
|
||||
t.Fatalf("p50=%v, want 20", got)
|
||||
}
|
||||
if got := percentileMS(values, 0.95); got != 40 {
|
||||
t.Fatalf("p95=%v, want 40", got)
|
||||
}
|
||||
if got := percentileMS(nil, 0.99); got != 0 {
|
||||
t.Fatalf("empty percentile=%v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigClampsWorkersAndRejectsInvalidQueue(t *testing.T) {
|
||||
queue := "0123456789abcdef0123456789abcdef"
|
||||
cfg, err := parseConfig([]string{"-url", "http://127.0.0.1:8080", "-token", "token", "-queue", queue, "-requests", "3", "-concurrency", "8"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Concurrency != 3 {
|
||||
t.Fatalf("concurrency=%d, want clamp to requests", cfg.Concurrency)
|
||||
}
|
||||
if _, err := parseConfig([]string{"-url", "http://127.0.0.1:8080", "-token", "token", "-queue", "bad"}); err == nil {
|
||||
t.Fatal("invalid queue accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCodeDoesNotExposeTransportText(t *testing.T) {
|
||||
if got := errorCode(&orbit.APIError{StatusCode: 409, Code: "IDEMPOTENCY_CONFLICT"}); got != "IDEMPOTENCY_CONFLICT" {
|
||||
t.Fatalf("api code=%q", got)
|
||||
}
|
||||
if got := errorCode(context.DeadlineExceeded); got != "TIMEOUT" {
|
||||
t.Fatalf("deadline code=%q", got)
|
||||
}
|
||||
if got := errorCode(errors.New("secret transport detail")); got != "TRANSPORT" {
|
||||
t.Fatalf("transport code=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSamplesUsesOneRequestPerSequenceWithoutRetry(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/tasks" {
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
requests.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"request_id":"0123456789abcdef0123456789abcdef","data":{"task":{},"deduplicated":false}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := orbit.NewClient(server.URL, orbit.Options{Token: "test-token"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := sampleConfig{QueueID: "0123456789abcdef0123456789abcdef", Requests: 7, Concurrency: 3, Timeout: time.Second, Handler: "load.noop", HandlerVersion: "v1", PayloadVersion: "v1"}
|
||||
results := runSamples(client, cfg, "0123456789abcdef0123456789abcdef")
|
||||
if len(results) != cfg.Requests || int(requests.Load()) != cfg.Requests {
|
||||
t.Fatalf("results=%d requests=%d, want %d", len(results), requests.Load(), cfg.Requests)
|
||||
}
|
||||
for _, result := range results {
|
||||
if result.code != "" {
|
||||
t.Fatalf("unexpected request failure: %s", result.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user