主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批: 已推库 16 个文件 + 本批剩余文件)。 校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0); 全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
243 lines
7.3 KiB
Go
243 lines
7.3 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
)
|
|
|
|
// 这些测试钉住生产中坏掉的那个契约(2026-10-03 21:00:28):
|
|
// MetricsCollector.Snapshot 必须交回脱离的计数器 map,因为
|
|
// 管理 handler 在收集器锁释放后对快照做 JSON 编码。
|
|
// 返回收集器自己的 map 会让并发的 Record 在编码器遍历它时
|
|
// 写该 map → 不可恢复的
|
|
// "fatal error: concurrent map iteration and map write" → 整个后端死亡,
|
|
// 随之所有管理页面也死(上报的「全链路流程功能无效」)。
|
|
|
|
// TestMetricsSnapshotDetachesMaps 是确定性的回归:从快照读出的
|
|
// 值不得在之后对收集器计数时改变,且写入
|
|
// 快照不得触达收集器。
|
|
func TestMetricsSnapshotDetachesMaps(t *testing.T) {
|
|
t.Parallel()
|
|
m := newMetricsCollector()
|
|
m.Record(http.MethodGet, "/api/v1/a", http.StatusOK, 1)
|
|
|
|
snap := m.Snapshot(time.Now())
|
|
if got := snap.PerPath["/api/v1/a"]; got != 1 {
|
|
t.Fatalf("PerPath[/api/v1/a] = %d, want 1", got)
|
|
}
|
|
if got := snap.PerMethod[http.MethodGet]; got != 1 {
|
|
t.Fatalf("PerMethod[GET] = %d, want 1", got)
|
|
}
|
|
|
|
// 快照之后记录的计数不得出现在较旧的快照中。
|
|
m.Record(http.MethodPost, "/api/v1/b", http.StatusTeapot, 1)
|
|
if _, ok := snap.PerPath["/api/v1/b"]; ok {
|
|
t.Fatalf("snapshot shares the collector's perPath map: later path leaked into it (%v)", snap.PerPath)
|
|
}
|
|
if _, ok := snap.PerMethod[http.MethodPost]; ok {
|
|
t.Fatalf("snapshot shares the collector's perMethod map: later method leaked into it (%v)", snap.PerMethod)
|
|
}
|
|
if _, ok := snap.StatusCounts["418"]; ok {
|
|
t.Fatalf("snapshot shares the collector's status map: later status leaked into it (%v)", snap.StatusCounts)
|
|
}
|
|
|
|
assertSnapshotWriteIsolation(t, m, snap)
|
|
assertCollectorNotMutatedBySnapshot(t, m)
|
|
}
|
|
|
|
// assertSnapshotWriteIsolation 写入快照的 map 并检查
|
|
// 之后收集器未被触碰。
|
|
func assertSnapshotWriteIsolation(t *testing.T, m *MetricsCollector, snap AdminMetrics) {
|
|
t.Helper()
|
|
snap.PerPath["/api/v1/evil"] = 999
|
|
snap.PerMethod["DELETE"] = 999
|
|
snap.StatusCounts["500"] = 999
|
|
|
|
after := m.Snapshot(time.Now())
|
|
if _, ok := after.PerPath["/api/v1/evil"]; ok {
|
|
t.Fatalf("collector's perPath is the snapshot's map: %v", after.PerPath)
|
|
}
|
|
if _, ok := after.PerMethod["DELETE"]; ok {
|
|
t.Fatalf("collector's perMethod is the snapshot's map: %v", after.PerMethod)
|
|
}
|
|
if _, ok := after.StatusCounts["500"]; ok {
|
|
t.Fatalf("collector's status map is the snapshot's map: %v", after.StatusCounts)
|
|
}
|
|
if after.TotalRequests != 2 {
|
|
t.Fatalf("TotalRequests = %d, want 2", after.TotalRequests)
|
|
}
|
|
if after.PerPath["/api/v1/b"] != 1 {
|
|
t.Fatalf("PerPath[/api/v1/b] = %d, want 1 (real count lost)", after.PerPath["/api/v1/b"])
|
|
}
|
|
}
|
|
|
|
// assertCollectorNotMutatedBySnapshot 重新读取收集器在隔离写入之前
|
|
// 记录的一个计数器,以证明没有任何计数值被扰动。
|
|
func assertCollectorNotMutatedBySnapshot(t *testing.T, m *MetricsCollector) {
|
|
t.Helper()
|
|
final := m.Snapshot(time.Now())
|
|
if final.PerPath["/api/v1/a"] != 1 {
|
|
t.Fatalf("PerPath[/api/v1/a] = %d, want 1", final.PerPath["/api/v1/a"])
|
|
}
|
|
if final.PerMethod[http.MethodGet] != 1 {
|
|
t.Fatalf("PerMethod[GET] = %d, want 1", final.PerMethod[http.MethodGet])
|
|
}
|
|
}
|
|
|
|
// TestMetricsSnapshotEncodedConcurrently 运行让生产崩溃的那个确切
|
|
// 交错:一个 goroutine 计请求(logging 中间件对每个请求都
|
|
// 这么做),另一个编码快照(管理 handler 在锁外
|
|
// 这么做)。在 -race 下共享活 map 会被报告为数据竞争;在
|
|
// 未打补丁的构建上它还可能直接把进程搞崩。
|
|
func TestMetricsSnapshotEncodedConcurrently(t *testing.T) {
|
|
t.Parallel()
|
|
m := newMetricsCollector()
|
|
stop := make(chan struct{})
|
|
var wg sync.WaitGroup
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
m.Record(http.MethodGet, "/api/v1/x", http.StatusOK, 1)
|
|
}
|
|
}()
|
|
|
|
deadline := time.Now().Add(300 * time.Millisecond)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := json.Marshal(m.Snapshot(time.Now())); err != nil {
|
|
close(stop)
|
|
wg.Wait()
|
|
t.Fatalf("marshal snapshot: %v", err)
|
|
}
|
|
}
|
|
close(stop)
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestAdminMetricsEndpointConcurrentPoll 驱动真实 HTTP 路径 —— 管理
|
|
// 面板并行轮询每个模块,因此一个请求可能在编码指标
|
|
// JSON,而另一个请求的 logging 中间件正在往收集器里记录。这
|
|
// 是生产崩溃经中间件 + handler 端到端复现。
|
|
func TestAdminMetricsEndpointConcurrentPoll(t *testing.T) {
|
|
env := newEnv(t, nil, false)
|
|
tok := env.token(t, auth.RoleOwner)
|
|
metricsURL := env.ts.URL + "/api/v1/admin/observability/metrics"
|
|
healthURL := env.ts.URL + "/api/v1/health"
|
|
|
|
// 两个任务并发运行:面板的指标读与持续往收集器
|
|
// 记录的负载请求相互竞争。
|
|
for _, err := range runConcurrent(
|
|
func() error { return driveRequests(healthURL, "", 4, 25) },
|
|
func() error { return driveRequests(metricsURL, tok, 6, 25) },
|
|
) {
|
|
if err != nil {
|
|
t.Fatalf("concurrent metrics poll: %v", err)
|
|
}
|
|
}
|
|
|
|
resp, body := doReq(t, http.MethodGet, metricsURL, nil, tok)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
var payload struct {
|
|
Data struct {
|
|
TotalRequests int64 `json:"totalRequests"`
|
|
PerPath map[string]int64 `json:"perPath"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatalf("decode metrics: %v (body=%s)", err, body)
|
|
}
|
|
if payload.Data.TotalRequests == 0 {
|
|
t.Fatal("totalRequests = 0, want the load above to be counted")
|
|
}
|
|
if payload.Data.PerPath["/api/v1/health"] == 0 {
|
|
t.Fatalf("perPath[/api/v1/health] = 0, want the health load counted (%v)", payload.Data.PerPath)
|
|
}
|
|
}
|
|
|
|
// runConcurrent 在各自 goroutine 中运行每个任务并返回非 nil 错误。
|
|
func runConcurrent(jobs ...func() error) []error {
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, len(jobs))
|
|
for i, job := range jobs {
|
|
wg.Add(1)
|
|
go func(i int, job func() error) {
|
|
defer wg.Done()
|
|
errs[i] = job()
|
|
}(i, job)
|
|
}
|
|
wg.Wait()
|
|
return errs
|
|
}
|
|
|
|
// driveRequests 对 url 发起 n 次 GET,其中 workers 个并行,token 非空
|
|
// 时作为 bearer 凭据。它返回首个失败(状态或 I/O)。
|
|
func driveRequests(url, token string, workers, n int) error {
|
|
var wg sync.WaitGroup
|
|
fail := make(chan error, workers)
|
|
|
|
for w := 0; w < workers; w++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < n; i++ {
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
select {
|
|
case fail <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
select {
|
|
case fail <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if err != nil {
|
|
select {
|
|
case fail <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
select {
|
|
case fail <- fmt.Errorf("GET %s status = %d (body=%s)", url, resp.StatusCode, body):
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(fail)
|
|
for err := range fail {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|