From 5ffd87f56b73602925a887faabd61fc91eb2b89e Mon Sep 17 00:00:00 2001 From: youfak Date: Fri, 7 Aug 2026 15:46:10 +0800 Subject: [PATCH] feat: persist loadgen reports --- README.md | 2 ++ cmd/proxy-loadgen/main.go | 48 ++++++++++++++++++++++++++++++++++ cmd/proxy-loadgen/main_test.go | 28 ++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/README.md b/README.md index 6f33c85..dcd525e 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,8 @@ go run ./cmd/proxy-loadgen ` 凭据,也不构成 100,000 QPS 证明。限速场景以 `Requests` 表示实际发起数;当 `RateStartsDropped` 非零时,目标速率受压测端并发容量或时间窗限制,报告吞吐不得 标注为已达到配置的 `-rate`。 +需要保留单次容量证据时,使用 `-output REPORT_FILE` 同时写出 JSON 文件;文件在 +同目录完整写入后才替换目标,标准输出仍保留相同报告,便于交给日志或指标系统。 ## 关键配置与入口 diff --git a/cmd/proxy-loadgen/main.go b/cmd/proxy-loadgen/main.go index a8dd231..7b0c98d 100644 --- a/cmd/proxy-loadgen/main.go +++ b/cmd/proxy-loadgen/main.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "time" @@ -41,6 +42,7 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io. 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") + outputPath := flags.String("output", "", "optional JSON report file; replaces only after a complete write") var headers headerValues flags.Var(&headers, "header", "repeatable HTTP header in Name: Value form") if err := flags.Parse(args); err != nil { @@ -53,6 +55,10 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io. _, _ = fmt.Fprintln(stderr, "proxy-loadgen: target and a bounded workload are required") return 2 } + if strings.TrimSpace(*outputPath) != *outputPath { + _, _ = fmt.Fprintln(stderr, "proxy-loadgen: invalid output path") + return 2 + } parsedHeaders, err := headers.Header() if err != nil { _, _ = fmt.Fprintf(stderr, "proxy-loadgen: %v\n", err) @@ -72,6 +78,10 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io. _, _ = fmt.Fprintf(stderr, "proxy-loadgen: %v\n", err) return 1 } + if err := writeReportFile(*outputPath, report); err != nil { + _, _ = fmt.Fprintf(stderr, "proxy-loadgen: write report: %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 @@ -79,6 +89,44 @@ func execute(ctx context.Context, args []string, run loadRun, stdout, stderr io. return 0 } +func writeReportFile(path string, report loadgen.Report) error { + if path == "" { + return nil + } + content, err := json.Marshal(report) + if err != nil { + return err + } + content = append(content, '\n') + directory := filepath.Dir(path) + temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(content); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + // Prefer same-directory rename so a completed report replaces the old one + // atomically on platforms that support replacement. + if err := os.Rename(temporaryPath, path); err == nil { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return os.Rename(temporaryPath, path) +} + type headerValues []string func (values *headerValues) String() string { diff --git a/cmd/proxy-loadgen/main_test.go b/cmd/proxy-loadgen/main_test.go index c6e8b41..b70ce7f 100644 --- a/cmd/proxy-loadgen/main_test.go +++ b/cmd/proxy-loadgen/main_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "os" + "path/filepath" "testing" "time" @@ -74,3 +76,29 @@ func TestExecutePassesExtractScenario(t *testing.T) { t.Fatalf("execute() = %d; options=%+v stderr=%q", code, received, stderr.String()) } } + +func TestExecuteWritesReportFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "load-report.json") + if err := os.WriteFile(path, []byte("incomplete"), 0o600); err != nil { + t.Fatalf("WriteFile(): %v", err) + } + var stdout, stderr bytes.Buffer + code := execute(context.Background(), []string{ + "-target", "https://target.example/health", "-requests", "1", "-concurrency", "1", "-output", path, + }, func(_ context.Context, _ loadgen.Options) (loadgen.Report, error) { + return loadgen.Report{Requests: 1, Completed: 1, Succeeded: 1, Duration: time.Second}, nil + }, &stdout, &stderr) + if code != 0 || stderr.Len() != 0 { + t.Fatalf("execute() = %d, stderr=%q", code, stderr.String()) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(): %v", err) + } + var report loadgen.Report + if err := json.Unmarshal(content, &report); err != nil || report.Succeeded != 1 || string(content) == "incomplete" { + t.Fatalf("report file = (%q, %+v, %v)", content, report, err) + } +}