From d991a285ba8646f6c75e2abf0d6d3470cd3ff5b7 Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 28 Aug 2026 19:42:19 +0800 Subject: [PATCH 1/5] =?UTF-8?q?chore=F0=9F=94=A7:=20upgrade=20go-admin-cor?= =?UTF-8?q?e=20to=20v2.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries four concurrency fixes and a bounded in-memory cache. The two that reach this repository are the search resolver, which no longer panics on an unexported field in a DTO and skips tag parsing for zero-valued ones, and the captcha driver, which is built once rather than per request. The cache bound does not apply here: config.CacheConfig.Setup() returns the older Memory implementation, which core leaves unbounded. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 10c367cc..eef3c025 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/casbin/casbin/v3 v3.8.1 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 - github.com/go-admin-team/go-admin-core/v2 v2.1.0 + github.com/go-admin-team/go-admin-core/v2 v2.2.0 github.com/google/uuid v1.6.0 github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible github.com/mssola/user_agent v0.6.0 diff --git a/go.sum b/go.sum index 0b84d085..b682fc96 100644 --- a/go.sum +++ b/go.sum @@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= -github.com/go-admin-team/go-admin-core/v2 v2.1.0 h1:v1RQkRT/sg0YvmS3m11mbtbijj6F3jUKs8EUaK64/+8= -github.com/go-admin-team/go-admin-core/v2 v2.1.0/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g= +github.com/go-admin-team/go-admin-core/v2 v2.2.0 h1:8aJk9q5RQ+T0a6gs95Ay93qzGUc5ilXv90RbXPiwnEI= +github.com/go-admin-team/go-admin-core/v2 v2.2.0/go.mod h1:YiJr2+vqC9qV5AoGeL+1W55h3XZ99CB5xWnP3Wo8c5g= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= From dcc2c8e175989bb6879ef5e09e06958251f271ee Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 28 Aug 2026 19:42:21 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix=F0=9F=94=92:=20stop=20logging=20the=20c?= =?UTF-8?q?aptcha=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The answer was written at info level on every captcha request, so a currently valid answer sat in the application log. Anyone able to read the log - an operator, a log aggregator, anything that ships logs off the host - could bypass the check the captcha exists to enforce. The default log level records it, so this was not limited to debug builds. --- app/admin/apis/captcha.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/admin/apis/captcha.go b/app/admin/apis/captcha.go index 0f3b319c..cab8be35 100644 --- a/app/admin/apis/captcha.go +++ b/app/admin/apis/captcha.go @@ -21,13 +21,16 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) { e.Error(500, err, "服务初始化失败!") return } - id, b64s, answer, err := captcha.DriverDigitFunc() + // The answer is deliberately discarded rather than logged. It used to be + // written at info level, which put a currently valid captcha answer in the + // application log - anyone able to read the log could bypass the check the + // captcha exists to enforce. + id, b64s, _, err := captcha.DriverDigitFunc() if err != nil { e.Logger.Errorf("DriverDigitFunc error, %s", err.Error()) e.Error(500, err, "验证码获取失败") return } - e.Logger.Infof("DriverDigitFunc answer: %s", answer) e.Custom(gin.H{ "code": 200, "data": b64s, From cd8edfa5d4a886083449f24280c88a43780ab56e Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 28 Aug 2026 19:42:21 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix=F0=9F=90=9B:=20reject=20rate-limited=20?= =?UTF-8?q?requests=20with=20429=20and=20make=20the=20threshold=20configur?= =?UTF-8?q?able?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rejected request answered 200 with the failure only in the body, so every layer that reads the status line counted it as served: load balancers, metrics, client-side retry. A load test against this reported the limiter's own rejections as successful traffic and overstated throughput more than tenfold. The threshold was a constant in the middleware, which made 200 QPS the ceiling of every deployment with nothing in the configuration to reveal it. It now reads extend.rateLimit.inboundQPS; an absent value keeps 200, so an upgrade changes nothing, and zero disables the limiter for a deployment behind its own gateway. Also drops Strategy: system.BBR. Reading sentinel's source, the adaptive strategy is consulted only for Load and CpuUsage - for InboundQPS the trigger count is compared directly - so it read as if the limit adapted to the machine when it never did. --- common/middleware/sentinel.go | 33 +++++++-- common/middleware/sentinel_test.go | 107 +++++++++++++++++++++++++++++ config/extend.go | 33 +++++++++ config/extend_test.go | 18 +++++ config/settings.full.yml | 7 ++ config/settings.yml | 7 ++ 6 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 common/middleware/sentinel_test.go diff --git a/common/middleware/sentinel.go b/common/middleware/sentinel.go index da6e5e89..58b897a7 100644 --- a/common/middleware/sentinel.go +++ b/common/middleware/sentinel.go @@ -1,29 +1,54 @@ package middleware import ( + "net/http" + "github.com/alibaba/sentinel-golang/core/system" sentinel "github.com/alibaba/sentinel-golang/pkg/adapters/gin" "github.com/gin-gonic/gin" log "github.com/go-admin-team/go-admin-core/v2/logger" + + "go-admin/config" ) // Sentinel 限流 +// +// The threshold comes from extend.ratelimit.inboundqps; see config.RateLimit +// for the values it accepts. func Sentinel() gin.HandlerFunc { + qps := config.ExtConfig.RateLimit.Threshold() + if qps <= 0 { + log.Info("rate limit disabled by extend.ratelimit.inboundqps") + return func(c *gin.Context) { c.Next() } + } + if _, err := system.LoadRules([]*system.Rule{ { MetricType: system.InboundQPS, - TriggerCount: 200, - Strategy: system.BBR, + TriggerCount: qps, + // InboundQPS is compared against TriggerCount directly - the + // adaptive strategy is only consulted for Load and CpuUsage. BBR + // stood here and read as if the limit adapted to the machine, which + // it never did. + Strategy: system.NoAdaptive, }, }); err != nil { log.Fatalf("Unexpected error: %+v", err) } + + log.Infof("rate limit: %.0f inbound req/s", qps) + return sentinel.SentinelMiddleware( sentinel.WithBlockFallback(func(ctx *gin.Context) { - ctx.AbortWithStatusJSON(200, map[string]interface{}{ + // 429, not 200. Everything that reads the status line rather than + // the body counts a 200 as served: load balancers, metrics, + // client-side retry, and load tests - a benchmark against the old + // behaviour reported the limiter's own rejections as successful + // traffic and overstated throughput by more than tenfold. + ctx.AbortWithStatusJSON(http.StatusTooManyRequests, map[string]interface{}{ "msg": "too many request; the quota used up!", - "code": 500, + "code": http.StatusTooManyRequests, }) }), ) diff --git a/common/middleware/sentinel_test.go b/common/middleware/sentinel_test.go new file mode 100644 index 00000000..35d521c4 --- /dev/null +++ b/common/middleware/sentinel_test.go @@ -0,0 +1,107 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alibaba/sentinel-golang/core/system" + "github.com/gin-gonic/gin" + + "go-admin/config" +) + +// serve builds a router with the limiter in front of a handler that always +// succeeds, so any non-200 comes from the limiter. +func serve(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Sentinel()) + r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) }) + return r +} + +func get(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ping", nil)) + return w +} + +// TestSentinelRejectsWithTooManyRequests pins the status code. A rejected +// request used to answer 200 with the failure only in the body, so every layer +// that reads the status line - load balancers, metrics, client retry, load +// tests - counted it as served. +func TestSentinelRejectsWithTooManyRequests(t *testing.T) { + one := 1.0 + config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &one} + t.Cleanup(func() { + config.ExtConfig.RateLimit = config.RateLimit{} + _ = system.ClearRules() + }) + + r := serve(t) + + var rejected *httptest.ResponseRecorder + for i := 0; i < 20; i++ { + if w := get(t, r); w.Code != http.StatusOK { + rejected = w + break + } + } + if rejected == nil { + t.Fatal("a limit of 1 req/s let 20 requests through; the limiter is not engaged") + } + if rejected.Code != http.StatusTooManyRequests { + t.Errorf("rejected with %d, want %d", rejected.Code, http.StatusTooManyRequests) + } + + // The body's code must agree with the status line; they disagreed before. + var body struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(rejected.Body.Bytes(), &body); err != nil { + t.Fatalf("rejection body is not json: %v", err) + } + if body.Code != http.StatusTooManyRequests { + t.Errorf("body code = %d, want %d", body.Code, http.StatusTooManyRequests) + } + if body.Msg == "" { + t.Error("rejection carries no message") + } +} + +// TestSentinelDisabledByZero covers the escape hatch: a deployment behind its +// own gateway has no use for a second limiter. +// +// It asserts on the loaded rules rather than on traffic. Sentinel measures QPS +// over a sliding window, so a burst issued inside one bucket is not counted +// before the bucket closes - a few hundred requests sail past a threshold of +// 200 in a test, and "no request was rejected" would pass whether or not the +// limiter is disabled. Whether a rule was installed at all does not depend on +// timing. +func TestSentinelDisabledByZero(t *testing.T) { + if err := system.ClearRules(); err != nil { + t.Fatal(err) + } + zero := 0.0 + config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &zero} + t.Cleanup(func() { + config.ExtConfig.RateLimit = config.RateLimit{} + _ = system.ClearRules() + }) + + r := serve(t) + if rules := system.GetRules(); len(rules) != 0 { + t.Errorf("limiter disabled but %d rule(s) were loaded: %+v", len(rules), rules) + } + + for i := 0; i < 500; i++ { + if w := get(t, r); w.Code != http.StatusOK { + t.Fatalf("request %d got %d with the limiter disabled", i, w.Code) + } + } +} diff --git a/config/extend.go b/config/extend.go index e8d6daa6..2caf4c01 100644 --- a/config/extend.go +++ b/config/extend.go @@ -12,6 +12,39 @@ var ExtConfig Extend type Extend struct { AMap AMap // 这里配置对应配置文件的结构即可 FileStore FileStore + RateLimit RateLimit +} + +// DefaultInboundQPS is the limit applied when nothing is configured. It is the +// value that used to be hard-coded in the middleware, so an existing deployment +// that adds nothing to settings.yml keeps the behaviour it already had. +const DefaultInboundQPS = 200 + +// RateLimit 全局入站限流。 +// +// extend: +// ratelimit: +// inboundqps: 200 # 每秒入站请求上限;填 0 关闭限流 +// +// The threshold used to live in common/middleware/sentinel.go as a constant, +// which made 200 QPS the ceiling of every deployment with nothing in the +// configuration to reveal it. +type RateLimit struct { + // InboundQPS caps inbound requests per second across the process. + // + // Absent means DefaultInboundQPS, zero disables the limiter, and a positive + // value is the threshold. The pointer is what separates "not configured" + // from "configured to zero" - the two need different answers and a plain + // float64 cannot tell them apart. + InboundQPS *float64 +} + +// Threshold reports the limit to apply. Zero means no limiting. +func (r RateLimit) Threshold() float64 { + if r.InboundQPS == nil { + return DefaultInboundQPS + } + return *r.InboundQPS } type AMap struct { diff --git a/config/extend_test.go b/config/extend_test.go index 230174f4..62b8f5cb 100644 --- a/config/extend_test.go +++ b/config/extend_test.go @@ -14,3 +14,21 @@ func TestObjectStoreConfigured(t *testing.T) { t.Fatal("partial store reported as configured") } } + +func TestRateLimitThreshold(t *testing.T) { + // Absent is the case an existing settings.yml hits after an upgrade: it has + // no ratelimit section, and must keep the limit it always had. + if got := (RateLimit{}).Threshold(); got != DefaultInboundQPS { + t.Errorf("unconfigured limit = %v, want the default %v", got, DefaultInboundQPS) + } + + zero := 0.0 + if got := (RateLimit{InboundQPS: &zero}).Threshold(); got != 0 { + t.Errorf("explicit zero = %v, want 0 so the limiter can be turned off", got) + } + + custom := 1500.0 + if got := (RateLimit{InboundQPS: &custom}).Threshold(); got != custom { + t.Errorf("configured limit = %v, want %v", got, custom) + } +} diff --git a/config/settings.full.yml b/config/settings.full.yml index a1fdacce..c3067364 100644 --- a/config/settings.full.yml +++ b/config/settings.full.yml @@ -56,6 +56,13 @@ settings: extend: # 扩展项使用说明 demo: name: data + # rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在 + # common/middleware/sentinel.go 里的值一致,升级不会改变行为。 + # 填 0 关闭限流——部署在自带限流的网关后面时用得上。 + # 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500, + # 会被负载均衡、监控和压测统计成成功)。 + rateLimit: + inboundQPS: 200 # fileStore 对象存储。上传接口的 source 参数决定走哪一家: # source=1 只存本地,source=2 阿里云 OSS,source=3 七牛 Kodo # 没有填的那一家在被请求时会返回明确错误,不会静默存到别处。 diff --git a/config/settings.yml b/config/settings.yml index 6891632c..058aabc0 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -48,6 +48,13 @@ settings: extend: # 扩展项使用说明 demo: name: data + # rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在 + # common/middleware/sentinel.go 里的值一致,升级不会改变行为。 + # 填 0 关闭限流——部署在自带限流的网关后面时用得上。 + # 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500, + # 会被负载均衡、监控和压测统计成成功)。 + rateLimit: + inboundQPS: 200 cache: # redis: # addr: 127.0.0.1:6379 From 1bc2e22833e55d9a00ee6c19916b6f8ac60d7fbc Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 28 Aug 2026 19:42:38 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix=F0=9F=90=9B:=20give=20the=20config=20te?= =?UTF-8?q?mplates=20the=20defaults=20a=20deployment=20actually=20needs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two settings that decide whether a deployment survives load, neither of which appeared in any template. The connection pool. Left unset, Go's defaults apply, and MaxIdleConns is 2: under load almost every request opens a TCP connection and closes it again, local ports run out, and the process answers "can't assign requested address" to everything. Not slower - unavailable. A sweep against MySQL collapsed to zero successful responses at 64 concurrent requests without these, and served 13,846 req/s with no errors once they were set. The queue buffer. poolSize is the point at which messages start being dropped, not a tuning knob: a full queue discards the message and returns an error rather than blocking, and each stream has one consumer goroutine writing to the database. At the previous default of 100 a load test lost over 60% of them; at 1000, none. Login and operation logs travel this queue, so what gets lost is audit data - though only when logger.enableddb is on. Both carry the reasoning in the file, because the failure mode of each is invisible until it happens in production. --- config/settings.full.yml | 21 ++++++++++++++++++++- config/settings.yml | 21 ++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/config/settings.full.yml b/config/settings.full.yml index c3067364..29754928 100644 --- a/config/settings.full.yml +++ b/config/settings.full.yml @@ -42,6 +42,17 @@ settings: source: user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms # source: sqlite3.db # source: host=myhost port=myport user=gorm dbname=gorm password=mypassword + # 连接池。不配置这几项时走 Go 的默认值,其中 MaxIdleConns 默认只有 2: + # 高并发下几乎每个请求都要新建 TCP 连接、用完立刻关闭,本机端口很快耗尽, + # 表现为 "can't assign requested address" 且请求全部失败——不是变慢,是不可用。 + # + # maxOpenConns 是单个实例的连接上限,多实例部署时总连接数是它乘以实例数, + # 需要小于数据库的 max_connections(MySQL 默认 151)。 + # connMaxLifeTime 单位为秒,应小于数据库的 wait_timeout(MySQL 默认 28800), + # 否则会复用到已被服务端关闭的连接。 + maxIdleConns: 20 + maxOpenConns: 100 + connMaxLifeTime: 3600 registers: - sources: - user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms @@ -52,7 +63,15 @@ settings: frontpath: ../go-admin-ui/src queue: memory: - poolSize: 100 + # poolSize 是队列的缓冲长度,不是并发度。队列满时 Append 会丢弃该消息并 + # 返回错误,而不是阻塞等待,所以这个值实际是「开始丢消息的临界点」。 + # + # 每个 stream 只有一个消费 goroutine,而登录日志、操作日志的消费要写数据库, + # 吞吐受限于单条写入耗时。突发流量高于消费速度时,缓冲区是唯一的缓解手段。 + # 压测中默认的 100 丢弃率超过 60%,1000 为 0。 + # + # 仅在 logger.enableddb 为 true 时才会真正入队。 + poolSize: 1000 extend: # 扩展项使用说明 demo: name: data diff --git a/config/settings.yml b/config/settings.yml index 058aabc0..15af8c19 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -32,6 +32,17 @@ settings: driver: mysql # 数据库连接字符串 mysql 缺省信息 charset=utf8&parseTime=True&loc=Local&timeout=1000ms source: user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8&parseTime=True&loc=Local&timeout=1000ms + # 连接池。不配置这几项时走 Go 的默认值,其中 MaxIdleConns 默认只有 2: + # 高并发下几乎每个请求都要新建 TCP 连接、用完立刻关闭,本机端口很快耗尽, + # 表现为 "can't assign requested address" 且请求全部失败——不是变慢,是不可用。 + # + # maxOpenConns 是单个实例的连接上限,多实例部署时总连接数是它乘以实例数, + # 需要小于数据库的 max_connections(MySQL 默认 151)。 + # connMaxLifeTime 单位为秒,应小于数据库的 wait_timeout(MySQL 默认 28800), + # 否则会复用到已被服务端关闭的连接。 + maxIdleConns: 20 + maxOpenConns: 100 + connMaxLifeTime: 3600 # databases: # 'locaohost:8000': # driver: mysql @@ -64,7 +75,15 @@ settings: memory: '' queue: memory: - poolSize: 100 + # poolSize 是队列的缓冲长度,不是并发度。队列满时 Append 会丢弃该消息并 + # 返回错误,而不是阻塞等待,所以这个值实际是「开始丢消息的临界点」。 + # + # 每个 stream 只有一个消费 goroutine,而登录日志、操作日志的消费要写数据库, + # 吞吐受限于单条写入耗时。突发流量高于消费速度时,缓冲区是唯一的缓解手段。 + # 压测中默认的 100 丢弃率超过 60%,1000 为 0。 + # + # 仅在 logger.enableddb 为 true 时才会真正入队。 + poolSize: 1000 # redis: # addr: 127.0.0.1:6379 # password: xxxxxx From ed74623a736db74b426a9440302062c019667c0a Mon Sep 17 00:00:00 2001 From: zhangwenjian Date: Fri, 28 Aug 2026 19:42:38 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test=E2=9C=85:=20add=20an=20end-to-end=20lo?= =?UTF-8?q?ad=20test=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipped unless GOADMIN_BENCH_ADDR points at a running server, so `go test ./...` is unaffected. Reports latency percentiles rather than an average, which is what capacity planning needs, and a status-code distribution - that last part is how the rate limiter's 200-on-rejection was found, since throughput alone looked excellent while nothing reached a handler. Includes a routing-floor control case. When a business endpoint matches it, the measurement has stopped describing the endpoint and started describing the transport, or the load generator when both share a machine. --- test/loadtest/loadtest_test.go | 367 +++++++++++++++++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 test/loadtest/loadtest_test.go diff --git a/test/loadtest/loadtest_test.go b/test/loadtest/loadtest_test.go new file mode 100644 index 00000000..7d116913 --- /dev/null +++ b/test/loadtest/loadtest_test.go @@ -0,0 +1,367 @@ +// Package loadtest measures what one go-admin process sustains over HTTP. +// +// It is skipped unless GOADMIN_BENCH_ADDR points at a running server, so +// `go test ./...` is unaffected. Start a server and run: +// +// GOADMIN_BENCH_ADDR=http://127.0.0.1:8000 go test ./test/loadtest/ -v -run TestLoadProfile +// +// Unlike a Go benchmark this reports latency percentiles, which is what +// capacity planning needs: an average hides the tail that users actually feel. +// +// Two caveats when reading the numbers. The load generator runs on the same +// machine as the server unless GOADMIN_BENCH_ADDR is remote, so both compete +// for the same cores - a split deployment measures higher. And the figures +// describe the configured backend: sqlite and MySQL differ by more than the +// framework does. +package loadtest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const ( + addrEnv = "GOADMIN_BENCH_ADDR" + tokenEnv = "GOADMIN_BENCH_TOKEN" + userEnv = "GOADMIN_BENCH_USER" + passEnv = "GOADMIN_BENCH_PASS" + + // Each concurrency level runs for this long. Long enough to get past + // connection setup and let the scheduler settle, short enough that the + // whole sweep stays interactive. + levelDuration = 3 * time.Second +) + +// concurrencyLevels sweeps from a single client to well past core count, so +// the point where added concurrency stops buying throughput is visible rather +// than assumed. Peak throughput and peak concurrency are not the same number: +// past the peak a server takes more work than it can finish and both +// throughput and latency get worse, so the sweep has to bracket the turn +// rather than stop at the top. +// +// GOADMIN_BENCH_LEVELS overrides it, comma separated. +var concurrencyLevels = parseLevels(os.Getenv("GOADMIN_BENCH_LEVELS"), []int{1, 2, 4, 8, 16, 32, 64, 128, 256, 512}) + +func parseLevels(spec string, fallback []int) []int { + if spec == "" { + return fallback + } + out := make([]int, 0, 8) + for _, f := range strings.Split(spec, ",") { + n, err := strconv.Atoi(strings.TrimSpace(f)) + if err != nil || n <= 0 { + continue + } + out = append(out, n) + } + if len(out) == 0 { + return fallback + } + return out +} + +func addr(t testing.TB) string { + t.Helper() + a := os.Getenv(addrEnv) + if a == "" { + t.Skipf("%s not set; skipping load test", addrEnv) + } + return a +} + +// newClient returns a client whose pool is large enough that the generator +// does not become the bottleneck it is trying to measure. +func newClient(maxConns int) *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: maxConns * 2, + MaxIdleConnsPerHost: maxConns * 2, + MaxConnsPerHost: maxConns * 2, + IdleConnTimeout: 90 * time.Second, + DisableCompression: true, + }, + } +} + +// result is one completed request. +type result struct { + latency time.Duration + err bool + status int +} + +// report is the summary of one concurrency level. +type report struct { + concurrency int + total int64 + failed int64 + elapsed time.Duration + p50, p95, p99, max time.Duration + statuses map[int]int64 +} + +func (r report) qps() float64 { + if r.elapsed == 0 { + return 0 + } + return float64(r.total) / r.elapsed.Seconds() +} + +func (r report) String() string { + codes := make([]int, 0, len(r.statuses)) + for c := range r.statuses { + codes = append(codes, c) + } + sort.Ints(codes) + dist := make([]string, 0, len(codes)) + for _, c := range codes { + dist = append(dist, fmt.Sprintf("%d:%d", c, r.statuses[c])) + } + return fmt.Sprintf("c=%-4d %9.0f req/s p50=%-9s p95=%-9s p99=%-9s max=%-9s failed=%-7d %s", + r.concurrency, r.qps(), + r.p50.Round(time.Microsecond), r.p95.Round(time.Microsecond), + r.p99.Round(time.Microsecond), r.max.Round(time.Microsecond), r.failed, + strings.Join(dist, " ")) +} + +// drive runs `concurrency` workers against req for levelDuration and collects +// every latency. Bodies are drained and closed - skipping that silently caps +// throughput at the point connections stop being reused. +func drive(t testing.TB, concurrency int, want int, mk func() *http.Request) report { + t.Helper() + + client := newClient(concurrency) + defer client.CloseIdleConnections() + + ctx, cancel := context.WithTimeout(context.Background(), levelDuration) + defer cancel() + + var ( + mu sync.Mutex + samples []time.Duration + statuses = map[int]int64{} + failed atomic.Int64 + total atomic.Int64 + wg sync.WaitGroup + ) + + start := time.Now() + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + local := make([]time.Duration, 0, 1024) + localStatus := map[int]int64{} + for ctx.Err() == nil { + req := mk() + t0 := time.Now() + resp, err := client.Do(req.WithContext(ctx)) + d := time.Since(t0) + if err != nil { + if ctx.Err() != nil { + break + } + failed.Add(1) + total.Add(1) + continue + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + localStatus[resp.StatusCode]++ + if resp.StatusCode != want { + failed.Add(1) + } + total.Add(1) + local = append(local, d) + } + mu.Lock() + samples = append(samples, local...) + for code, n := range localStatus { + statuses[code] += n + } + mu.Unlock() + }() + } + wg.Wait() + elapsed := time.Since(start) + + sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] }) + r := report{ + concurrency: concurrency, + total: total.Load(), + failed: failed.Load(), + elapsed: elapsed, + statuses: statuses, + } + if n := len(samples); n > 0 { + r.p50 = samples[n*50/100] + r.p95 = samples[min(n*95/100, n-1)] + r.p99 = samples[min(n*99/100, n-1)] + r.max = samples[n-1] + } + return r +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// login obtains a token. GOADMIN_BENCH_TOKEN short-circuits it, which is how a +// server in prod mode is reached - there the login endpoint demands a captcha. +func login(t testing.TB, base string) string { + t.Helper() + if tok := os.Getenv(tokenEnv); tok != "" { + return tok + } + + user, pass := os.Getenv(userEnv), os.Getenv(passEnv) + if user == "" { + user, pass = "admin", "123456" + } + + body, _ := json.Marshal(map[string]string{ + "username": user, + "password": pass, + "code": "0", + "uuid": "0", + }) + resp, err := http.Post(base+"/api/v1/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("login request failed: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login returned %d: %s\n(a server in prod mode requires a captcha; set %s instead)", + resp.StatusCode, raw, tokenEnv) + } + var out struct { + Token string `json:"token"` + } + if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" { + t.Fatalf("no token in login response: %s", raw) + } + return out.Token +} + +// TestLoadProfile sweeps concurrency against three endpoints chosen for what +// they isolate: +// +// - captcha: no auth, no business query. The routing and image-generation +// floor. +// - dept list: the full authenticated path - JWT parse, casbin check, data +// permission scope, database read. This is what a real page costs. +// - login: bcrypt. Deliberately slow, and the one endpoint whose ceiling is +// set by design rather than by the framework. +func TestLoadProfile(t *testing.T) { + base := addr(t) + token := login(t, base) + + cases := []struct { + name string + want int + mk func() *http.Request + }{ + { + // The control. An unrouted path exercises the HTTP stack, gin's + // tree lookup and nothing else, so it bounds every other row here. + // When a business endpoint reaches this number, the measurement has + // stopped describing the endpoint and started describing the + // transport - or the load generator, when both share a machine. + name: "404 (http+routing floor)", + want: 404, + mk: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/__no_such_route__", nil) + return req + }, + }, + { + // The framework on its own: global middleware chain, route lookup, + // and a handler that only sets a status. No database, no cache. + // Against the 404 row this isolates what the chain costs; against + // the rows below it, what the business path adds. + // + // Numbers from any endpoint that touches a database describe the + // database, the driver and the pool as much as the framework - the + // MySQL sweeps here moved from collapsing at c=64 to 19k req/s at + // c=512 on a pool setting alone, with the framework untouched. + name: "health (framework only, no db)", + want: 200, + mk: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/health", nil) + return req + }, + }, + { + name: "captcha (no auth)", + want: 200, + mk: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/captcha", nil) + return req + }, + }, + { + name: "dept list (jwt+casbin+db)", + want: 200, + mk: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/dept", nil) + req.Header.Set("Authorization", "Bearer "+token) + return req + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, c := range concurrencyLevels { + t.Log(drive(t, c, tc.want, tc.mk)) + } + }) + } +} + +// TestLoginThroughput is separated because bcrypt saturates the CPU: running +// it alongside the others would distort them. It also writes a login-log row +// per attempt when logger.enableddb is on, so the number moves with that +// setting. +func TestLoginThroughput(t *testing.T) { + base := addr(t) + if os.Getenv(tokenEnv) != "" { + t.Skip("token supplied; login endpoint presumably needs a captcha") + } + + user, pass := os.Getenv(userEnv), os.Getenv(passEnv) + if user == "" { + user, pass = "admin", "123456" + } + body, _ := json.Marshal(map[string]string{ + "username": user, "password": pass, "code": "0", "uuid": "0", + }) + + mk := func() *http.Request { + req, _ := http.NewRequest(http.MethodPost, base+"/api/v1/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req + } + + for _, c := range []int{1, 4, 8, 16, 32, 64, 128} { + t.Log(drive(t, c, http.StatusOK, mk)) + } +}