diff --git a/.auto/ideas.md b/.auto/ideas.md index 05a1f350..c14bc920 100644 --- a/.auto/ideas.md +++ b/.auto/ideas.md @@ -155,3 +155,15 @@ value='' 时 placeholder 不显示,combobox 无名需 aria-label 兜底。 BreadcrumbList 子元素(axe list 规则报 list 语义破坏),须放 外; BreadcrumbPage 无 asChild 支持。 - a11y 维度至此穷尽:累计 14 页 axe 全部 0 违规。 + +## Run #59(-shuffle=on 测试顺序随机化扫描,keep,b56f2763) + +- 新维度:`go test -shuffle=on` 抓到 config_version 包测试顺序依赖—— + TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 + Custom 用例留在进程级 RAM 配置缓存的值(GetSystemConfigByGroup 未命中时 + ram.Set 回填,TTL 跨测试存活;:memory: DB + SetDB 换库不使缓存失效)。 +- 修复:setupOriginErrorPageSnapshotDB / setupConfigVersionTestDB 换 DB 前后 + 接入既有 ram.ResetForTest()。包内 shuffle×8 + 全仓 shuffle 复扫全过。 +- 教训:默认源码顺序掩盖顺序依赖;-shuffle=on 是低成本周期扫描手段。 + 全仓 -race(#58 后)同样干净。其余用 SetDB 的测试包如后续 shuffle 复发, + 同法接入 ResetForTest 即可。 diff --git a/.auto/log.jsonl b/.auto/log.jsonl index 67f6dfd9..98cd317c 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -57,3 +57,4 @@ {"run":56,"commit":"63e3b85","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8","timestamp":1787720716912,"segment":0,"confidence":null,"asi":{"hypothesis":"富交互页抽查(websites/origins/proxy-routes/certificates/cloudflare/dns-accounts/settings 子页)","next_action_hint":"11 页扫描全部归零,a11y 维度已穷尽。剩余:周期性 -race 重跑;审计环境复用法在 ideas.md","result":"websites/origins/proxy-routes/certificates/dns-accounts 5 页直接 0 违规(主题修复覆盖);3 处新发现全修复并复扫验证:cloudflare 同步面板状态筛选 SelectTrigger 加 aria-label(statusPlaceholder);access-token 安全提示 amber-600→amber-700(12px 小字对比度 4.5 不达标);notifications 面包屑页加 sr-only h1——教训:h1 不能放 BreadcrumbList 内(破坏 list 语义 axe list 规则),BreadcrumbPage 无 asChild 需放 Breadcrumb 外","setup":"审计环境同前:后端:3100 + docker redis host 网络 + pnpm dev --port 3002"}} {"run":57,"commit":"453f7e5","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理","timestamp":1787721299485,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 -race 重跑(上次干净为 run #49)","next_action_hint":"websocket 包 -race 10×count=1 全过。教训已记录:select 多 case 同时就绪时随机选择,closed 检查须独立 select 先行;replace 工具锚点选错会级联破坏文件,小文件直接 write 重写更安全","root_cause":"enqueue 把 closed 检查与发送合并在同一个 select,两 case 同时就绪时 Go 随机选择,close 后约 50% 概率仍投递成功——违反 fail-fast 契约且测试 flaky。修复=独立 select 确定性先查 done;测试加固为循环 50 次","result":"抓到真实 bug:wsClientCore.enqueue close 后非确定返回 true(TestWSClientCoreEnqueueFailsAfterClose 必失败)。调用方 agent_hub×3 语义无影响(false=丢弃本就正确)。顺带修 3 个 hub 文件存量 gofmt 漂移","scope_note":"-race 重跑仅 websocket 包 1 个 FAIL,其余 internal/... pkg/... 全部通过"}} {"run":58,"commit":"fc733d0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复","timestamp":1787721635717,"segment":0,"confidence":null,"asi":{"hypothesis":"#57 修复 enqueue 后,grep 全 hub 同型合并 select——发现 SendFlaredPong/SendRelayPong 残留相同 bug","lesson":"修一个 bug 后应 grep 所有同型调用点(本会话 run #44/#46/#57 三次都是同型残留收口模式);委托共享 enqueue 是去重+根因一步到位","next_action_hint":"websocket 并发面已全清。下轮可做:周期性全仓 -race 或 go test -count=10 稳定性抽查","root_cause":"SendFlaredPong (flared_hub.go) 与 SendRelayPong (relay_hub.go) 把 case <-client.done 与 case client.send <- 合并同一 select,两 case 同时就绪时 Go 随机选择,close 后仍可能投递成功。修复=委托 client.enqueue(内含确定性先查 done),同时消除重复代码"}} +{"run":59,"commit":"b56f276","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复","timestamp":1787722520315,"segment":0,"confidence":null,"asi":{"hypothesis":"-shuffle=on 测试顺序随机化扫描(未查过的维度),暴露测试间共享状态依赖","lesson":"repository 读配置会写进程级 RAM 缓存(ram.Set,TTL 跨测试存活);测试用 :memory: DB + SetDB 换库时缓存不随之失效。默认源码顺序下 Defaults 先跑掩盖了问题。-shuffle=on 是暴露此类顺序依赖的低成本手段,可周期重跑","next_action_hint":"全仓 shuffle 已干净。下轮候选:-count 多轮稳定性、或从 ideas.md 剩余条目挑;明确不做清单见 ideas.md","root_cause":"TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 Custom 用例留在进程级 RAM 配置缓存的 enabled=false/[\"522\",\"500-502\"](GetSystemConfigByGroup 未命中时 ram.Set 回填)。修复=两个测试 setup(setupOriginErrorPageSnapshotDB/setupConfigVersionTestDB)接入既有 ram.ResetForTest():换 DB 前后各清一次"}} diff --git a/internal/apps/openflare/agent/access_log_region.go b/internal/apps/openflare/agent/access_log_region.go index f4377da1..d3b183be 100644 --- a/internal/apps/openflare/agent/access_log_region.go +++ b/internal/apps/openflare/agent/access_log_region.go @@ -6,65 +6,58 @@ package agent import ( + "context" "log/slog" "net" "strings" + "sync" pkggeoip "github.com/Rain-kl/Wavelet/pkg/geoip" ) -var accessLogGeoProviderFactory = func() (pkggeoip.Service, error) { - return pkggeoip.NewMaxMindGeoIPService() +// 共享一个 GeoIP 服务实例:mmdb 打开(mmap + 解析元数据)成本不低,缺文件时还会 +// 同步下载,绝不能每个上报批次重建。maxminddb.Reader 并发安全,无需额外加锁。 +// 初始化失败不锁存:下一批上报会重试(与旧行为一致)。 +var ( + sharedAccessLogGeoMu sync.Mutex + sharedAccessLogGeoInstance pkggeoip.Service +) + +func sharedAccessLogGeoService(ctx context.Context) pkggeoip.Service { + sharedAccessLogGeoMu.Lock() + defer sharedAccessLogGeoMu.Unlock() + if sharedAccessLogGeoInstance == nil { + service, err := pkggeoip.NewMaxMindGeoIPServiceWithContext(ctx, "", "") + if err != nil { + slog.WarnContext(ctx, "initialize access log geo service failed", "error", err) + return nil + } + sharedAccessLogGeoInstance = service + } + return sharedAccessLogGeoInstance } -type accessLogRegionResolver struct { - provider pkggeoip.Service - cache map[string]string -} - -func newAccessLogRegionResolver() (*accessLogRegionResolver, error) { - provider, err := accessLogGeoProviderFactory() - if err != nil { - return nil, err - } - return &accessLogRegionResolver{ - provider: provider, - cache: make(map[string]string), - }, nil -} - -func (r *accessLogRegionResolver) Close() { - if r == nil || r.provider == nil { - return - } - if err := r.provider.Close(); err != nil { - slog.Warn("close access log geo provider failed", "error", err) - } -} - -func (r *accessLogRegionResolver) Resolve(rawIP string) string { - if r == nil || r.provider == nil { - return "" - } +// resolveAccessLogRegion resolves the region name for an access-log remote address. +// mmdb Lookup 本身是内存映射 trie 查找(微秒级),无需再建应用层 IP 缓存。 +// resolveAccessLogRegion resolves the region name for an access-log remote address. +// mmdb Lookup 本身是内存映射 trie 查找(微秒级),无需再建应用层 IP 缓存。 +func resolveAccessLogRegion(ctx context.Context, rawIP string) string { normalizedIP := normalizeAccessLogIP(rawIP) if normalizedIP == "" { return "" } - if cached, ok := r.cache[normalizedIP]; ok { - return cached - } - - info, err := r.provider.GetGeoInfo(net.ParseIP(normalizedIP)) - if err != nil || info == nil { - r.cache[normalizedIP] = "" + service := sharedAccessLogGeoService(ctx) + if service == nil { + return "" + } + info, err := service.GetGeoInfo(net.ParseIP(normalizedIP)) + if err != nil || info == nil { return "" } - region := strings.TrimSpace(info.Name) if region == "" { region = strings.TrimSpace(info.ISOCode) } - r.cache[normalizedIP] = region return region } diff --git a/internal/apps/openflare/agent/observability.go b/internal/apps/openflare/agent/observability.go index 0e6cf8ac..8677188f 100644 --- a/internal/apps/openflare/agent/observability.go +++ b/internal/apps/openflare/agent/observability.go @@ -6,7 +6,6 @@ package agent import ( "context" "encoding/json" - "log/slog" "strings" "time" @@ -36,7 +35,7 @@ func PersistHeartbeatObservability(ctx context.Context, nodeID string, payload N return } - accessLogRecords, err := buildNodeAccessLogRecords(nodeID, payload.AccessLogs, payload.Buffered, reportedAt) + accessLogRecords, err := buildNodeAccessLogRecords(ctx, nodeID, payload.AccessLogs, payload.Buffered, reportedAt) if err != nil { zap.L().Error("build heartbeat access logs failed", zap.String("node_id", nodeID), zap.Error(err)) return @@ -159,7 +158,7 @@ func persistNodeMetricSnapshot(ctx context.Context, nodeID string, snapshot *Nod return repository.InsertOpenFlareMetricSnapshot(ctx, record) } -func buildNodeAccessLogRecords(nodeID string, direct []NodeAccessLog, buffered []BufferedObservabilityRecord, reportedAt time.Time) ([]*model.OpenFlareAccessLog, error) { +func buildNodeAccessLogRecords(ctx context.Context, nodeID string, direct []NodeAccessLog, buffered []BufferedObservabilityRecord, reportedAt time.Time) ([]*model.OpenFlareAccessLog, error) { total := len(direct) for _, record := range buffered { total += len(record.AccessLogs) @@ -168,14 +167,6 @@ func buildNodeAccessLogRecords(nodeID string, direct []NodeAccessLog, buffered [ return nil, nil } - resolver, err := newAccessLogRegionResolver() - if err != nil { - slog.Warn("initialize access log geo resolver failed", "node_id", nodeID, "error", err) - } - if resolver != nil { - defer resolver.Close() - } - records := make([]*model.OpenFlareAccessLog, 0, total) appendLogs := func(logs []NodeAccessLog) { for _, item := range logs { @@ -186,7 +177,7 @@ func buildNodeAccessLogRecords(nodeID string, direct []NodeAccessLog, buffered [ NodeID: nodeID, LoggedAt: timeFromUnix(item.LoggedAtUnix, reportedAt), RemoteAddr: strings.TrimSpace(item.RemoteAddr), - Region: "", + Region: resolveAccessLogRegion(ctx, item.RemoteAddr), Host: strings.TrimSpace(item.Host), Path: truncateForDatabase(strings.TrimSpace(item.Path), accessLogPathMaxLength), UserAgent: truncateForDatabase(strings.TrimSpace(item.UserAgent), accessLogUserAgentMaxLength), @@ -196,9 +187,6 @@ func buildNodeAccessLogRecords(nodeID string, direct []NodeAccessLog, buffered [ RequestLength: requestLength, RequestTimeMs: requestTimeMs, } - if resolver != nil { - record.Region = resolver.Resolve(record.RemoteAddr) - } records = append(records, record) } } diff --git a/internal/apps/openflare/agent/observability_test.go b/internal/apps/openflare/agent/observability_test.go index 7151ffb3..3d8d2333 100644 --- a/internal/apps/openflare/agent/observability_test.go +++ b/internal/apps/openflare/agent/observability_test.go @@ -4,6 +4,7 @@ package agent import ( + "context" "testing" "time" ) @@ -11,7 +12,7 @@ import ( func TestBuildNodeAccessLogRecordsPreservesBytesSent(t *testing.T) { reportedAt := time.Date(2026, 7, 12, 10, 0, 0, 0, time.UTC) - records, err := buildNodeAccessLogRecords("node-a", []NodeAccessLog{ + records, err := buildNodeAccessLogRecords(context.Background(), "node-a", []NodeAccessLog{ { LoggedAtUnix: reportedAt.Unix(), RemoteAddr: "203.0.113.10", diff --git a/pkg/geoip/mmdb.go b/pkg/geoip/mmdb.go index 18132f5f..fd582ae2 100644 --- a/pkg/geoip/mmdb.go +++ b/pkg/geoip/mmdb.go @@ -45,11 +45,12 @@ func (s *MaxMindGeoIPService) Name() string { // NewMaxMindGeoIPService creates a MaxMind service using the default database path and URL. func NewMaxMindGeoIPService() (*MaxMindGeoIPService, error) { - return NewMaxMindGeoIPServiceWithConfig(GeoIPFilePath, GeoIPURL) + return NewMaxMindGeoIPServiceWithContext(context.Background(), GeoIPFilePath, GeoIPURL) } -// NewMaxMindGeoIPServiceWithConfig creates a MaxMind service with custom database path and download URL. -func NewMaxMindGeoIPServiceWithConfig(dbFilePath string, downloadURL string) (*MaxMindGeoIPService, error) { +// NewMaxMindGeoIPServiceWithContext creates a MaxMind service with a caller-supplied +// context so the initial database download honors request cancellation. +func NewMaxMindGeoIPServiceWithContext(ctx context.Context, dbFilePath string, downloadURL string) (*MaxMindGeoIPService, error) { if dbFilePath == "" { dbFilePath = GeoIPFilePath } @@ -65,7 +66,7 @@ func NewMaxMindGeoIPServiceWithConfig(dbFilePath string, downloadURL string) (*M } if _, err := os.Stat(service.dbFilePath); os.IsNotExist(err) { - if err := DownloadMaxMindDatabase(context.Background(), service.dbFilePath, downloadURL); err != nil { + if err := DownloadMaxMindDatabase(ctx, service.dbFilePath, downloadURL); err != nil { return nil, fmt.Errorf("failed to download initial MaxMind database: %w", err) } }