Files
Cyrene_v2/internal/services/gateway/observability_metrics_test.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

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
}