From f968b09d327e638df6d7410c923f03bee4ff4de0 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Sun, 21 Jun 2026 19:06:38 +0200 Subject: [PATCH 01/11] bench(v1.5.4): cut NIC-saturated scenarios (W1) Remove the rows whose RPS converges at the 20G fabric line rate (the harness already flags them network_bound): get-json-8k/16k/64k + post-8k/16k/64k (H1) and get-json-64k-h2 + post-64k-h2. These burned ~288 cells/run (~5h) without differentiating fast adapters. Keep post-1m as the SINGLE documented wire-bound datapoint for the saturation/methodology discussion (not a ranking row). Drop the now-unused post8k/16k/64k payload generators; update the registry/category/body-size test guards. Net static rows: 12 H1 -> 6, 4 H2 -> 2. Funds the driver + WS/SSE depth. --- scenarios/scenarios_test.go | 29 +++++------ scenarios/static.go | 95 +++++++------------------------------ 2 files changed, 28 insertions(+), 96 deletions(-) diff --git a/scenarios/scenarios_test.go b/scenarios/scenarios_test.go index feba60e..5657ba2 100644 --- a/scenarios/scenarios_test.go +++ b/scenarios/scenarios_test.go @@ -12,26 +12,20 @@ import ( // files and are deliberately excluded here — this test guards the slice // we own, not the ones we don't. var expectedRegistry = []string{ - // static H1 (12) + // static H1 (6) — v1.5.4 cut the NIC-bound 8k/16k/64k GET + 8k/16k/64k + // POST rows; post-1m kept as the single documented wire-bound datapoint. "churn-close", "get-json", "get-json-1k", - "get-json-8k", - "get-json-16k", - "get-json-64k", "get-simple", "post-1m", "post-4k", - "post-8k", - "post-16k", - "post-64k", - // static H2-prior-knowledge (4) — exercise h2c-noupg and other - // HTTP2C-capable cells that the H1 variants skip. - "get-json-64k-h2", + // static H2-prior-knowledge (2) — exercise h2c-noupg and other + // HTTP2C-capable cells that the H1 variants skip. v1.5.4 cut the + // saturated 64k h2 rows. "get-json-h2", "post-4k-h2", - "post-64k-h2", // concurrency (5) "get-json-1c", @@ -107,7 +101,6 @@ func TestStaticPOSTBodiesExactSize(t *testing.T) { want int }{ {"post-4k", 4 * 1024}, - {"post-64k", 64 * 1024}, {"post-1m", 1024 * 1024}, } for _, tc := range cases { @@ -135,8 +128,8 @@ func TestStaticScenariosRequireHTTP1(t *testing.T) { h1Only := servers.FeatureSet{HTTP1: true} h2cOnly := servers.FeatureSet{HTTP2C: true} for _, name := range []string{ - "churn-close", "get-json", "get-json-1k", "get-json-64k", - "get-simple", "post-1m", "post-4k", "post-64k", + "churn-close", "get-json", "get-json-1k", + "get-simple", "post-1m", "post-4k", } { s := findScenario(t, name) if !s.Applicable(h1Only) { @@ -216,7 +209,7 @@ func TestStaticH2ScenariosRequireHTTP2C(t *testing.T) { t.Parallel() h1Only := servers.FeatureSet{HTTP1: true} h2cOnly := servers.FeatureSet{HTTP2C: true} - for _, name := range []string{"get-json-h2", "get-json-64k-h2", "post-4k-h2", "post-64k-h2"} { + for _, name := range []string{"get-json-h2", "post-4k-h2"} { s := findScenario(t, name) if s.Applicable(h1Only) { t.Errorf("%q: applicable to HTTP1-only server (H2 scenario needs HTTP2C)", name) @@ -230,9 +223,9 @@ func TestStaticH2ScenariosRequireHTTP2C(t *testing.T) { func TestCategories(t *testing.T) { t.Parallel() for _, name := range []string{ - "churn-close", "get-json", "get-json-1k", "get-json-64k", - "get-simple", "post-1m", "post-4k", "post-64k", - "get-json-h2", "get-json-64k-h2", "post-4k-h2", "post-64k-h2", + "churn-close", "get-json", "get-json-1k", + "get-simple", "post-1m", "post-4k", + "get-json-h2", "post-4k-h2", } { s := findScenario(t, name) if got := s.Category(); got != CategoryStatic { diff --git a/scenarios/static.go b/scenarios/static.go index 137dedc..7db23a9 100644 --- a/scenarios/static.go +++ b/scenarios/static.go @@ -140,21 +140,19 @@ var StaticScenarioNames = []string{ "get-simple", "get-json", "get-json-1k", - "get-json-8k", - "get-json-16k", - "get-json-64k", "post-4k", - "post-8k", - "post-16k", - "post-64k", + // post-1m is the single deliberate wire-bound row kept for the + // saturation/methodology discussion — NOT a ranking signal (RPS converges + // at line rate). The 8k/16k/64k GET + 8k/16k/64k POST rows were cut in + // v1.5.4: every fast adapter converges at the 20G fabric ceiling, so they + // burned compute without differentiating. See report network_bound flags. "post-1m", "churn-close", // HTTP/2 prior-knowledge variants — exercise every HTTP2C-capable - // server, including h2c-noupg (which refuses H1 entirely). + // server, including h2c-noupg (which refuses H1 entirely). The 64k h2 + // rows were cut in v1.5.4 (NIC-bound like their H1 twins). "get-json-h2", - "get-json-64k-h2", "post-4k-h2", - "post-64k-h2", } // Pre-generated POST payloads. They are built once at package init() so @@ -165,11 +163,8 @@ var StaticScenarioNames = []string{ // Each payload uses its own deterministic seed so a future change to one // size leaves the others byte-identical. var ( - post4KBody = makeRandomBody(4*1024, 0xA11CE_4000) - post8KBody = makeRandomBody(8*1024, 0xA11CE_8000) - post16KBody = makeRandomBody(16*1024, 0xB0B_16000) - post64KBody = makeRandomBody(64*1024, 0xB0B_64000) - post1MBody = makeRandomBody(1024*1024, 0xC0DE_10000) + post4KBody = makeRandomBody(4*1024, 0xA11CE_4000) + post1MBody = makeRandomBody(1024*1024, 0xC0DE_10000) ) // makeRandomBody returns a byte slice of exactly n bytes filled with a @@ -226,30 +221,6 @@ func init() { Path: "/json-1k", Connections: 128, }) - // Mid-size GET payloads (8k/16k). The 64k cells are NIC-bound on the 20G - // LACP fabric — every fast adapter converges at the line rate, so raw RPS - // stops differentiating them. 8k/16k responses stay well under the - // ceiling (a server doing 100k RPS of 16k is ~13 Gbps, still CPU-bound), - // so these rows recover the response-serialisation throughput signal the - // 64k row loses to the wire. - Register(&StaticScenario{ - name: "get-json-8k", - Method: "GET", - Path: "/json-8k", - Connections: 128, - }) - Register(&StaticScenario{ - name: "get-json-16k", - Method: "GET", - Path: "/json-16k", - Connections: 128, - }) - Register(&StaticScenario{ - name: "get-json-64k", - Method: "GET", - Path: "/json-64k", - Connections: 128, - }) Register(&StaticScenario{ name: "post-4k", Method: "POST", @@ -257,31 +228,12 @@ func init() { Body: post4KBody, Connections: 128, }) - // Mid-size POST bodies (8k/16k). Same NIC-ceiling rationale as the - // mid-size GET rows, on the upload (request-body parse) axis — and free - // of any per-adapter route work, since every adapter already serves - // /upload. - Register(&StaticScenario{ - name: "post-8k", - Method: "POST", - Path: "/upload", - Body: post8KBody, - Connections: 128, - }) - Register(&StaticScenario{ - name: "post-16k", - Method: "POST", - Path: "/upload", - Body: post16KBody, - Connections: 128, - }) - Register(&StaticScenario{ - name: "post-64k", - Method: "POST", - Path: "/upload", - Body: post64KBody, - Connections: 128, - }) + // post-1m is kept deliberately as the ONE wire-bound datapoint for the + // saturation/methodology discussion. The 8k/16k/64k GET and 8k/16k/64k + // POST rows were removed in v1.5.4: on the 20G LACP fabric every fast + // adapter converges at the line rate, so raw RPS stopped differentiating + // them (report flags them network_bound). Document post-1m as NIC-bound, + // not a ranking row. Register(&StaticScenario{ name: "post-1m", Method: "POST", @@ -324,13 +276,6 @@ func init() { Connections: 32, HTTP2: true, }) - Register(&StaticScenario{ - name: "get-json-64k-h2", - Method: "GET", - Path: "/json-64k", - Connections: 32, - HTTP2: true, - }) Register(&StaticScenario{ name: "post-4k-h2", Method: "POST", @@ -339,12 +284,6 @@ func init() { Connections: 32, HTTP2: true, }) - Register(&StaticScenario{ - name: "post-64k-h2", - Method: "POST", - Path: "/upload", - Body: post64KBody, - Connections: 32, - HTTP2: true, - }) + // get-json-64k-h2 + post-64k-h2 were removed in v1.5.4 — saturated + // large-body h2 (NIC-bound like their H1 64k twins). } From 67751c487d85b253eece8507054eb7c3ab80f4bb Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Sun, 21 Jun 2026 19:13:02 +0200 Subject: [PATCH 02/11] bench(v1.5.4): driver scenario taxonomy 4->10 + bench_writes fixture (W3 part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define the 6 new driver-depth scenarios (writes / transaction / range / pipeline / multiget) + their routes/bodies, and add the unlogged bench_writes PG table + FixtureRedisWriteKey to services. Verified: scenarios build/vet + the registry/category tests pass with 10 driver scenarios. NEXT (W3 part 2): implement the 6 handlers across the 9 driver adapters (servers/*/driver_handlers.go) — celeris via its native driver/{postgres, redis,memcached} (Pipeline/BeginTx/GetMulti all confirmed present), Go competitors via idiomatic pgx/go-redis/gomemcache; standardize the memcached env var; add conformance routes. Until then these rows 404 (feature branch only; not runnable yet). --- scenarios/driver.go | 55 +++++++++++++++++++++++++++++++++++++++----- services/services.go | 17 ++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/scenarios/driver.go b/scenarios/driver.go index 3ab10e3..9ed2e27 100644 --- a/scenarios/driver.go +++ b/scenarios/driver.go @@ -7,16 +7,32 @@ import ( "github.com/goceleris/probatorium/services" ) -// DriverKind names the 4 driver-backed scenarios. +// DriverKind names the driver-backed scenarios. v1.5.4 deepened the set +// from 4 single-op reads to 10 — adding writes, an explicit transaction, a +// multi-row range, a pipelined batch, and a multi-key fetch — so the +// native-driver-vs-ecosystem comparison becomes multi-dimensional instead +// of a single GET number. const ( - DriverPG = "driver-pg-read" - DriverRedis = "driver-redis-get" - DriverMemcached = "driver-mc-get" - DriverSession = "driver-session-rw" + DriverPG = "driver-pg-read" // GET /db/user/42 — 1 SELECT (hot row) + DriverRedis = "driver-redis-get" // GET /cache/ — 1 GET + DriverMemcached = "driver-mc-get" // GET /mc/ — 1 GET + DriverSession = "driver-session-rw" // POST /session — GET + SET round-trip + + // v1.5.4 depth additions: + DriverPGWrite = "driver-pg-write" // POST /db/insert — 1 INSERT (write path) + DriverPGUpdateTx = "driver-pg-update-tx" // POST /db/tx/user/42 — BEGIN;UPDATE;COMMIT + DriverPGReadRange = "driver-pg-read-range" // GET /db/users?limit=50 — N-row result set + DriverRedisSet = "driver-redis-set" // POST /cache — 1 SET (write path) + DriverRedisPipeline = "driver-redis-pipeline" // GET /cache/pipeline?n=10 — pipelined GETs + DriverMCMultiGet = "driver-mc-multiget" // GET /mc/multi?keys=10 — multi-key fetch ) // DriverKinds is the canonical ordered list of driver scenarios. -var DriverKinds = []string{DriverPG, DriverRedis, DriverMemcached, DriverSession} +var DriverKinds = []string{ + DriverPG, DriverRedis, DriverMemcached, DriverSession, + DriverPGWrite, DriverPGUpdateTx, DriverPGReadRange, + DriverRedisSet, DriverRedisPipeline, DriverMCMultiGet, +} // sessionBody is the 256-byte payload POSTed by driver-session-rw. It is // deterministic so repeat runs send byte-identical requests — any @@ -91,6 +107,27 @@ func (s *DriverScenario) Workload(target string) loadgen.Config { cfg.Method = "POST" cfg.URL = target + "/session" cfg.Body = sessionBody + case DriverPGWrite: + cfg.Method = "POST" + cfg.URL = target + "/db/insert" + cfg.Body = sessionBody // 256B payload inserted into bench_writes + case DriverPGUpdateTx: + cfg.Method = "POST" + cfg.URL = target + "/db/tx/user/42" // BEGIN;UPDATE score+1;COMMIT on the hot row + cfg.Body = sessionBody // body ignored by the handler + case DriverPGReadRange: + cfg.Method = "GET" + cfg.URL = target + "/db/users?limit=50" // 50-row SELECT -> JSON array + case DriverRedisSet: + cfg.Method = "POST" + cfg.URL = target + "/cache" + cfg.Body = sessionBody // SET services.FixtureRedisWriteKey = body + case DriverRedisPipeline: + cfg.Method = "GET" + cfg.URL = target + "/cache/pipeline?n=10" // 10x GET FixtureDemoKey, pipelined + case DriverMCMultiGet: + cfg.Method = "GET" + cfg.URL = target + "/mc/multi?keys=10" // GetMulti of 10 seeded session keys } return cfg } @@ -112,4 +149,10 @@ func init() { Register(NewDriverScenario(DriverRedis, DriverRedis)) Register(NewDriverScenario(DriverMemcached, DriverMemcached)) Register(NewDriverScenario(DriverSession, DriverSession)) + Register(NewDriverScenario(DriverPGWrite, DriverPGWrite)) + Register(NewDriverScenario(DriverPGUpdateTx, DriverPGUpdateTx)) + Register(NewDriverScenario(DriverPGReadRange, DriverPGReadRange)) + Register(NewDriverScenario(DriverRedisSet, DriverRedisSet)) + Register(NewDriverScenario(DriverRedisPipeline, DriverRedisPipeline)) + Register(NewDriverScenario(DriverMCMultiGet, DriverMCMultiGet)) } diff --git a/services/services.go b/services/services.go index 49838e7..e684b2a 100644 --- a/services/services.go +++ b/services/services.go @@ -35,6 +35,10 @@ const ( FixtureDemoKey = "demo-key" FixtureSessionIDMin = 1 FixtureSessionIDMax = 1000 + + // v1.5.4 driver-depth fixtures. + FixtureWritesTable = "bench_writes" // unlogged PG table for driver-pg-write + FixtureRedisWriteKey = "demo-write" // key driver-redis-set writes (no seed; the bench writes it) ) // Kind enumerates the services probatorium can provision. String values @@ -487,6 +491,19 @@ func seedPostgres(ctx context.Context, dsn string) error { return err } + // bench_writes: scratch target for driver-pg-write. UNLOGGED so the + // write-path bench measures driver + server overhead, not WAL fsync + // noise. Truncated on each seed for determinism. + if _, err := conn.Exec(ctx, `DROP TABLE IF EXISTS `+FixtureWritesTable); err != nil { + return err + } + if _, err := conn.Exec(ctx, `CREATE UNLOGGED TABLE `+FixtureWritesTable+` ( + id BIGSERIAL PRIMARY KEY, + payload TEXT NOT NULL + )`); err != nil { + return err + } + rows := make([][]any, 0, FixtureUserMaxID-FixtureUserMinID+1) for i := FixtureUserMinID; i <= FixtureUserMaxID; i++ { rows = append(rows, []any{ From 682d853cc0239d6ea401ac9669d6aa7183080735 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Sun, 21 Jun 2026 19:22:59 +0200 Subject: [PATCH 03/11] bench(v1.5.4): implement the 6 driver-depth handlers across all 9 adapters (W3 part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pg-write / pg-update-tx (BEGIN-UPDATE-COMMIT) / pg-read-range (N-row) / redis-set / redis-pipeline (batched GETs) / mc-multiget to every driver adapter: celeris via its native driver/{postgres,redis,memcached} (Pipeline / BeginTx / GetMulti), the 8 Go competitors via idiomatic pgx/go-redis/ gomemcache, each matching the adapter's framework idiom. Routes are /cache-pipeline and /mc-multiget (not /cache/pipeline, /mc/multi) to avoid colliding with the /cache/:key and /mc/:key param routes. Also standardize the memcached env var: fasthttp/echo/iris read PROBATORIUM_MC_ADDR while the other 6 read PROBATORIUM_MEMCACHED_ADDR — a latent bug where validate.yml (which only set MC_ADDR) 503'd the 6 MEMCACHED_ADDR adapters on every memcached cell. All read MEMCACHED_ADDR now; validate.yml fixed; run_bench_cell.yml dual-set collapsed. All 9 adapter modules build; scenarios test green (10 driver scenarios). --- ansible/tasks/run_bench_cell.yml | 8 +- ansible/validate.yml | 2 +- scenarios/driver.go | 24 ++-- servers/celeris/driver_handlers.go | 156 +++++++++++++++++++++ servers/chi/driver_handlers.go | 169 +++++++++++++++++++++++ servers/echo/driver_handlers.go | 143 ++++++++++++++++++- servers/echo/server.go | 2 +- servers/fasthttp/driver_handlers.go | 207 +++++++++++++++++++++++++++- servers/fiber/driver_handlers.go | 138 +++++++++++++++++++ servers/gin/driver_handlers.go | 160 +++++++++++++++++++++ servers/hertz/driver_handlers.go | 160 +++++++++++++++++++++ servers/iris/driver_handlers.go | 169 ++++++++++++++++++++++- servers/stdhttp/driver_handlers.go | 187 +++++++++++++++++++++++++ 13 files changed, 1504 insertions(+), 21 deletions(-) diff --git a/ansible/tasks/run_bench_cell.yml b/ansible/tasks/run_bench_cell.yml index d9d6dfa..93bcaa1 100644 --- a/ansible/tasks/run_bench_cell.yml +++ b/ansible/tasks/run_bench_cell.yml @@ -168,9 +168,10 @@ # dbservices role only installs docker + pulls images; it never runs a # container). Every driver-capable competitor reads its backend address # from these PROBATORIUM_* vars; an unset var leaves that client nil and - # the driver cell 404s (→ capability-lie hard error). gin/celeris read - # PROBATORIUM_MEMCACHED_ADDR, echo reads PROBATORIUM_MC_ADDR, so we set - # both spellings. Harmless for static-only competitors (they ignore them). + # the driver cell 404s (→ capability-lie hard error). Every adapter reads + # PROBATORIUM_MEMCACHED_ADDR (standardized in v1.5.4 — fasthttp/echo/iris + # used to read PROBATORIUM_MC_ADDR). Harmless for static-only competitors + # (they ignore them). # Ports MUST match bench.yml's container -p mappings AND validate.yml: # postgres 54321 redis 63791 memcached 21211 (remapped off the # defaults so a stray system postgres/redis on the bench host can't shadow @@ -179,7 +180,6 @@ PROBATORIUM_PG_DSN: "postgres://bench:bench@127.0.0.1:54321/bench?sslmode=disable" PROBATORIUM_REDIS_ADDR: "127.0.0.1:63791" PROBATORIUM_MEMCACHED_ADDR: "127.0.0.1:21211" - PROBATORIUM_MC_ADDR: "127.0.0.1:21211" # aspnet (.NET) is a FRAMEWORK-DEPENDENT publish: the native apphost # (competitors/aspnet) must locate the shared .NET runtime at launch. We # installed the SDK (which carries the runtime) pristinely under diff --git a/ansible/validate.yml b/ansible/validate.yml index 2abc6bb..4915b9e 100644 --- a/ansible/validate.yml +++ b/ansible/validate.yml @@ -158,7 +158,7 @@ # run is a no-op. PROBATORIUM_PG_DSN: "postgres://bench:bench@127.0.0.1:54321/bench?sslmode=disable" PROBATORIUM_REDIS_ADDR: "127.0.0.1:63791" - PROBATORIUM_MC_ADDR: "127.0.0.1:21211" + PROBATORIUM_MEMCACHED_ADDR: "127.0.0.1:21211" ansible.builtin.shell: | cd {{ bench_root }} ulimit -l unlimited diff --git a/scenarios/driver.go b/scenarios/driver.go index 9ed2e27..d7f3485 100644 --- a/scenarios/driver.go +++ b/scenarios/driver.go @@ -13,18 +13,18 @@ import ( // native-driver-vs-ecosystem comparison becomes multi-dimensional instead // of a single GET number. const ( - DriverPG = "driver-pg-read" // GET /db/user/42 — 1 SELECT (hot row) - DriverRedis = "driver-redis-get" // GET /cache/ — 1 GET - DriverMemcached = "driver-mc-get" // GET /mc/ — 1 GET + DriverPG = "driver-pg-read" // GET /db/user/42 — 1 SELECT (hot row) + DriverRedis = "driver-redis-get" // GET /cache/ — 1 GET + DriverMemcached = "driver-mc-get" // GET /mc/ — 1 GET DriverSession = "driver-session-rw" // POST /session — GET + SET round-trip // v1.5.4 depth additions: - DriverPGWrite = "driver-pg-write" // POST /db/insert — 1 INSERT (write path) - DriverPGUpdateTx = "driver-pg-update-tx" // POST /db/tx/user/42 — BEGIN;UPDATE;COMMIT - DriverPGReadRange = "driver-pg-read-range" // GET /db/users?limit=50 — N-row result set - DriverRedisSet = "driver-redis-set" // POST /cache — 1 SET (write path) - DriverRedisPipeline = "driver-redis-pipeline" // GET /cache/pipeline?n=10 — pipelined GETs - DriverMCMultiGet = "driver-mc-multiget" // GET /mc/multi?keys=10 — multi-key fetch + DriverPGWrite = "driver-pg-write" // POST /db/insert — 1 INSERT (write path) + DriverPGUpdateTx = "driver-pg-update-tx" // POST /db/tx/user/42 — BEGIN;UPDATE;COMMIT + DriverPGReadRange = "driver-pg-read-range" // GET /db/users?limit=50 — N-row result set + DriverRedisSet = "driver-redis-set" // POST /cache — 1 SET (write path) + DriverRedisPipeline = "driver-redis-pipeline" // GET /cache-pipeline?n=10 — pipelined GETs + DriverMCMultiGet = "driver-mc-multiget" // GET /mc-multiget?keys=10 — multi-key fetch ) // DriverKinds is the canonical ordered list of driver scenarios. @@ -124,10 +124,12 @@ func (s *DriverScenario) Workload(target string) loadgen.Config { cfg.Body = sessionBody // SET services.FixtureRedisWriteKey = body case DriverRedisPipeline: cfg.Method = "GET" - cfg.URL = target + "/cache/pipeline?n=10" // 10x GET FixtureDemoKey, pipelined + // Distinct path (not /cache/pipeline) so it can't collide with the + // /cache/:key param route in any of the framework routers. + cfg.URL = target + "/cache-pipeline?n=10" // 10x GET FixtureDemoKey, pipelined case DriverMCMultiGet: cfg.Method = "GET" - cfg.URL = target + "/mc/multi?keys=10" // GetMulti of 10 seeded session keys + cfg.URL = target + "/mc-multiget?keys=10" // GetMulti of 10 seeded session keys } return cfg } diff --git a/servers/celeris/driver_handlers.go b/servers/celeris/driver_handlers.go index 2e1ecdb..c3afc06 100644 --- a/servers/celeris/driver_handlers.go +++ b/servers/celeris/driver_handlers.go @@ -156,6 +156,15 @@ func mountDriverHandlers(srv *celeris.Server) *driverClients { srv.GET("/cache/:key", c.cacheHandler).Async() srv.GET("/mc/:key", c.mcHandler).Async() + // v1.5.4 driver-depth routes (writes / transaction / range / pipeline / + // multiget). Same WithEngine().Async() discipline as the reads above. + srv.POST("/db/insert", c.dbInsertHandler).Async() + srv.POST("/db/tx/user/:id", c.dbTxHandler).Async() + srv.GET("/db/users", c.dbUsersRangeHandler).Async() + srv.POST("/cache", c.cacheSetHandler).Async() + srv.GET("/cache-pipeline", c.cachePipelineHandler).Async() + srv.GET("/mc-multiget", c.mcMultiGetHandler).Async() + // The session middleware is mounted as a per-route layer (not globally) // so its load/save round-trip only fires on /session requests. When // Redis is unavailable the route degrades to a deterministic 503. @@ -220,6 +229,153 @@ func (c *driverClients) mcHandler(ctx *celeris.Context) error { return ctx.Blob(200, "application/octet-stream", val) } +// dbInsertHandler serves POST /db/insert: INSERT the request body into the +// unlogged bench_writes table (driver-pg-write; "bench_writes" matches +// services.FixtureWritesTable). nil pool / exec error -> 503. +func (c *driverClients) dbInsertHandler(ctx *celeris.Context) error { + if c.pg == nil { + return ctx.AbortWithStatus(503) + } + qctx, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) + defer cancel() + if _, err := c.pg.ExecContext(qctx, + "INSERT INTO bench_writes(payload) VALUES($1)", string(ctx.Body()), + ); err != nil { + return ctx.AbortWithStatus(503) + } + return ctx.JSON(200, sessionResponse{OK: true}) +} + +// dbTxHandler serves POST /db/tx/user/:id: BEGIN; UPDATE score+1; COMMIT +// (driver-pg-update-tx) — an explicit transaction round-trip on the hot row. +func (c *driverClients) dbTxHandler(ctx *celeris.Context) error { + if c.pg == nil { + return ctx.AbortWithStatus(503) + } + id, err := strconv.Atoi(ctx.Param("id")) + if err != nil { + return ctx.AbortWithStatus(400) + } + qctx, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) + defer cancel() + tx, err := c.pg.BeginTx(qctx, nil) + if err != nil { + return ctx.AbortWithStatus(503) + } + if _, err := tx.ExecContext(qctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback() + return ctx.AbortWithStatus(503) + } + if err := tx.Commit(); err != nil { + return ctx.AbortWithStatus(503) + } + return ctx.JSON(200, sessionResponse{OK: true, Seq: id}) +} + +// dbUsersRangeHandler serves GET /db/users?limit=N: SELECT the first N rows +// and JSON-encode the array (driver-pg-read-range) — result-set marshalling +// rather than a single-row read. +func (c *driverClients) dbUsersRangeHandler(ctx *celeris.Context) error { + if c.pg == nil { + return ctx.AbortWithStatus(503) + } + limit, err := strconv.Atoi(ctx.Query("limit")) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + qctx, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) + defer cancel() + rows, err := c.pg.QueryContext(qctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit, + ) + if err != nil { + return ctx.AbortWithStatus(503) + } + defer func() { _ = rows.Close() }() + out := make([]userRow, 0, limit) + for rows.Next() { + var r userRow + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Score); err != nil { + return ctx.AbortWithStatus(503) + } + out = append(out, r) + } + if rows.Err() != nil { + return ctx.AbortWithStatus(503) + } + return ctx.JSON(200, out) +} + +// cacheSetHandler serves POST /cache: SET demo-write = request body +// (driver-redis-set; "demo-write" matches services.FixtureRedisWriteKey). +func (c *driverClients) cacheSetHandler(ctx *celeris.Context) error { + if c.redis == nil { + return ctx.AbortWithStatus(503) + } + qctx, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) + defer cancel() + if err := c.redis.SetBytes(qctx, "demo-write", ctx.Body(), 0); err != nil { + return ctx.AbortWithStatus(503) + } + return ctx.JSON(200, sessionResponse{OK: true}) +} + +// cachePipelineHandler serves GET /cache-pipeline?n=N: pipeline N GETs of +// demo-key in one round-trip (driver-redis-pipeline) — the native-driver +// batching differentiator. "demo-key" matches services.FixtureDemoKey. +func (c *driverClients) cachePipelineHandler(ctx *celeris.Context) error { + if c.redis == nil { + return ctx.AbortWithStatus(503) + } + n, err := strconv.Atoi(ctx.Query("n")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + qctx, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) + defer cancel() + p := c.redis.Pipeline() + defer p.Release() + cmds := make([]*redis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = p.Get("demo-key") + } + if err := p.Exec(qctx); err != nil { + return ctx.AbortWithStatus(503) + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Result() + if err != nil { + return ctx.AbortWithStatus(503) + } + total += len(v) + } + return ctx.JSON(200, sessionResponse{OK: true, Seq: total}) +} + +// mcMultiGetHandler serves GET /mc-multiget?keys=N: GetMulti of N seeded +// user::session keys in one batch (driver-mc-multiget). +func (c *driverClients) mcMultiGetHandler(ctx *celeris.Context) error { + if c.mc == nil { + return ctx.AbortWithStatus(503) + } + n, err := strconv.Atoi(ctx.Query("keys")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + qctx, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) + defer cancel() + vals, err := c.mc.GetMulti(qctx, keys...) + if err != nil { + return ctx.AbortWithStatus(503) + } + return ctx.JSON(200, sessionResponse{OK: true, Seq: len(vals)}) +} + // sessionTerminal is the inner handler the session middleware wraps (via // Route.Use): the middleware loads the session, calls c.Next() into this // terminal, then saves on the way out. A loadgen client that reuses the diff --git a/servers/chi/driver_handlers.go b/servers/chi/driver_handlers.go index c5f0ccf..4b4a66f 100644 --- a/servers/chi/driver_handlers.go +++ b/servers/chi/driver_handlers.go @@ -28,6 +28,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "io" "net/http" "os" "strconv" @@ -130,6 +131,174 @@ func mountDriverHandlers(r chi.Router) { r.Get("/cache/{key}", c.cacheHandler) r.Get("/mc/{key}", c.mcHandler) r.Post("/session", c.sessionHandler) + + // v1.5.4 driver-depth routes (idiomatic pgx/go-redis/gomemcache). + r.Post("/db/insert", c.dbInsertHandler) + r.Post("/db/tx/user/{id}", c.dbTxHandler) + r.Get("/db/users", c.dbUsersRangeHandler) + r.Post("/cache", c.cacheSetHandler) + r.Get("/cache-pipeline", c.cachePipelineHandler) + r.Get("/mc-multiget", c.mcMultiGetHandler) +} + +// dbInsertHandler serves POST /db/insert: INSERT the body into bench_writes. +func (c *driverClients) dbInsertHandler(w http.ResponseWriter, r *http.Request) { + if c.pg == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + body, _ := io.ReadAll(r.Body) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if _, err := c.pg.Exec(ctx, "INSERT INTO bench_writes(payload) VALUES($1)", string(body)); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + writeJSON(w, sessionResponse{OK: true}) +} + +// dbTxHandler serves POST /db/tx/user/{id}: BEGIN; UPDATE score+1; COMMIT. +func (c *driverClients) dbTxHandler(w http.ResponseWriter, r *http.Request) { + if c.pg == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + id, err := strconv.Atoi(chi.URLParam(r, "id")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + tx, err := c.pg.Begin(ctx) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if _, err := tx.Exec(ctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(ctx) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if err := tx.Commit(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + writeJSON(w, sessionResponse{OK: true, Seq: id}) +} + +// dbUsersRangeHandler serves GET /db/users?limit=N: SELECT N rows -> JSON array. +func (c *driverClients) dbUsersRangeHandler(w http.ResponseWriter, r *http.Request) { + if c.pg == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + rows, err := c.pg.Query(ctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var row userRow + if err := rows.Scan(&row.ID, &row.Name, &row.Email, &row.Score); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + out = append(out, row) + } + if rows.Err() != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + writeJSON(w, out) +} + +// cacheSetHandler serves POST /cache: SET demo-write = body. +func (c *driverClients) cacheSetHandler(w http.ResponseWriter, r *http.Request) { + if c.redis == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + body, _ := io.ReadAll(r.Body) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if err := c.redis.Set(ctx, "demo-write", body, 0).Err(); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + writeJSON(w, sessionResponse{OK: true}) +} + +// cachePipelineHandler serves GET /cache-pipeline?n=N: pipeline N GETs of demo-key. +func (c *driverClients) cachePipelineHandler(w http.ResponseWriter, r *http.Request) { + if c.redis == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(r.URL.Query().Get("n")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + pipe := c.redis.Pipeline() + cmds := make([]*redis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(ctx, "demo-key") + } + if _, err := pipe.Exec(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + total += len(v) + } + writeJSON(w, sessionResponse{OK: true, Seq: total}) +} + +// mcMultiGetHandler serves GET /mc-multiget?keys=N: GetMulti of N session keys. +func (c *driverClients) mcMultiGetHandler(w http.ResponseWriter, r *http.Request) { + if c.mc == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(r.URL.Query().Get("keys")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := c.mc.GetMulti(keys) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + writeJSON(w, sessionResponse{OK: true, Seq: len(items)}) +} + +// writeJSON encodes v as the 200 JSON response body, matching the existing +// handlers' Content-Type/WriteHeader/Encode sequence. +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) } // dbUserHandler serves GET /db/user/{id}: SELECT id,name,email,score and diff --git a/servers/echo/driver_handlers.go b/servers/echo/driver_handlers.go index 8e8cf1d..e0e1c2b 100644 --- a/servers/echo/driver_handlers.go +++ b/servers/echo/driver_handlers.go @@ -60,7 +60,7 @@ type driverClients struct { // envOr returns the env var value for key, or def when unset/empty. The // service endpoints are injected by the orchestrator as PROBATORIUM_PG_DSN -// / PROBATORIUM_REDIS_ADDR / PROBATORIUM_MC_ADDR (see ansible/validate.yml); +// / PROBATORIUM_REDIS_ADDR / PROBATORIUM_MEMCACHED_ADDR (see ansible/validate.yml); // the -postgres-dsn / -redis-addr / -mc-addr flags override them. func envOr(key, def string) string { if v := os.Getenv(key); v != "" { @@ -229,4 +229,145 @@ func registerDriverHandlers(e *echov4.Echo, dc *driverClients) { return c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: int(seq)}) }) + + // v1.5.4 driver-depth routes (idiomatic pgx/go-redis/gomemcache). The + // paths are /cache-pipeline and /mc-multiget rather than nested under the + // /cache and /mc :key routes so they do not collide with those param + // segments. + + // POST /db/insert — INSERT the request body into bench_writes. + e.POST("/db/insert", func(c echov4.Context) error { + if dc.pg == nil { + return c.NoContent(http.StatusServiceUnavailable) + } + body, _ := io.ReadAll(c.Request().Body) + ctx, cancel := context.WithTimeout(c.Request().Context(), driverOpTimeout) + defer cancel() + if _, err := dc.pg.Exec(ctx, + "INSERT INTO bench_writes(payload) VALUES($1)", string(body), + ); err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + return c.JSON(http.StatusOK, sessionResponse{OK: true}) + }) + + // POST /db/tx/user/:id — BEGIN; UPDATE score+1; COMMIT (Rollback on err). + e.POST("/db/tx/user/:id", func(c echov4.Context) error { + if dc.pg == nil { + return c.NoContent(http.StatusServiceUnavailable) + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.NoContent(http.StatusBadRequest) + } + ctx, cancel := context.WithTimeout(c.Request().Context(), driverOpTimeout) + defer cancel() + tx, err := dc.pg.Begin(ctx) + if err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + if _, err := tx.Exec(ctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(ctx) + return c.NoContent(http.StatusServiceUnavailable) + } + if err := tx.Commit(ctx); err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + return c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: id}) + }) + + // GET /db/users?limit=N — SELECT N seeded rows -> JSON array. + e.GET("/db/users", func(c echov4.Context) error { + if dc.pg == nil { + return c.NoContent(http.StatusServiceUnavailable) + } + limit, err := strconv.Atoi(c.QueryParam("limit")) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + ctx, cancel := context.WithTimeout(c.Request().Context(), driverOpTimeout) + defer cancel() + rows, err := dc.pg.Query(ctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit) + if err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var r userRow + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Score); err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + out = append(out, r) + } + if rows.Err() != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + return c.JSON(http.StatusOK, out) + }) + + // POST /cache — Redis SET demo-write = request body, no expiry. + e.POST("/cache", func(c echov4.Context) error { + if dc.redis == nil { + return c.NoContent(http.StatusServiceUnavailable) + } + body, _ := io.ReadAll(c.Request().Body) + ctx, cancel := context.WithTimeout(c.Request().Context(), driverOpTimeout) + defer cancel() + if err := dc.redis.Set(ctx, "demo-write", body, 0).Err(); err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + return c.JSON(http.StatusOK, sessionResponse{OK: true}) + }) + + // GET /cache-pipeline?n=N — pipeline N GETs of demo-key, sum byte lengths. + e.GET("/cache-pipeline", func(c echov4.Context) error { + if dc.redis == nil { + return c.NoContent(http.StatusServiceUnavailable) + } + n, err := strconv.Atoi(c.QueryParam("n")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + ctx, cancel := context.WithTimeout(c.Request().Context(), driverOpTimeout) + defer cancel() + pipe := dc.redis.Pipeline() + cmds := make([]*redis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(ctx, "demo-key") + } + if _, err := pipe.Exec(ctx); err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + total += len(v) + } + return c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: total}) + }) + + // GET /mc-multiget?keys=N — GetMulti of N seeded session keys. + e.GET("/mc-multiget", func(c echov4.Context) error { + if dc.mc == nil { + return c.NoContent(http.StatusServiceUnavailable) + } + n, err := strconv.Atoi(c.QueryParam("keys")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := dc.mc.GetMulti(keys) + if err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + return c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: len(items)}) + }) } diff --git a/servers/echo/server.go b/servers/echo/server.go index 0080215..ff77b28 100644 --- a/servers/echo/server.go +++ b/servers/echo/server.go @@ -25,7 +25,7 @@ func main() { engine := flag.String("engine", "h1", "runtime engine: h1 or h2c") pgDSN := flag.String("postgres-dsn", envOr("PROBATORIUM_PG_DSN", ""), "postgres DSN for the driver-pg route") redisAddr := flag.String("redis-addr", envOr("PROBATORIUM_REDIS_ADDR", ""), "redis host:port for the driver-redis / session routes") - mcAddr := flag.String("mc-addr", envOr("PROBATORIUM_MC_ADDR", ""), "memcached host:port for the driver-mc route") + mcAddr := flag.String("mc-addr", envOr("PROBATORIUM_MEMCACHED_ADDR", ""), "memcached host:port for the driver-mc route") flag.Parse() e := echo.New() diff --git a/servers/fasthttp/driver_handlers.go b/servers/fasthttp/driver_handlers.go index 9d42f49..75a6ecf 100644 --- a/servers/fasthttp/driver_handlers.go +++ b/servers/fasthttp/driver_handlers.go @@ -2,7 +2,7 @@ // driver routes (postgres / redis / memcached / session). Clients are // opened from the backend addresses the runner exports in the child // environment (PROBATORIUM_PG_DSN / PROBATORIUM_REDIS_ADDR / -// PROBATORIUM_MC_ADDR — the same vars the validation refapps read). An +// PROBATORIUM_MEMCACHED_ADDR — the same vars the validation refapps read). An // absent or unreachable backend leaves the matching route returning 503 // so the runner's per-cell guard records a clean error rather than a // panic or a silent 0-RPS cell. @@ -38,7 +38,7 @@ import ( const ( envPGDSN = "PROBATORIUM_PG_DSN" envRedisAddr = "PROBATORIUM_REDIS_ADDR" - envMCAddr = "PROBATORIUM_MC_ADDR" + envMCAddr = "PROBATORIUM_MEMCACHED_ADDR" ) // driverPoolSize bounds each driver's connection pool so the bench is @@ -127,6 +127,209 @@ func mountDriverHandlers(s *Server) { s.MountNative(http.MethodGet, cachePrefix, cacheHandler(cachePrefix)) s.MountNative(http.MethodGet, mcPrefix, mcHandler(mcPrefix)) s.MountNative(http.MethodPost, sessionPath, sessionHandler()) + + // v1.5.4 driver-depth routes (idiomatic pgx/go-redis/gomemcache). The + // literal paths /cache-pipeline and /mc-multiget are deliberately not + // /cache/... or /mc/... so they don't collide with the cache/mc prefix + // routes above. /db/tx/user/:id is a prefix route (the id is sliced off + // the path); the rest are exact matches and the limit/n/keys parameters + // arrive as query args. + const dbTxPrefix = "/db/tx/user/" + s.MountNative(http.MethodPost, "/db/insert", dbInsertHandler()) + s.MountNative(http.MethodPost, dbTxPrefix, dbTxHandler(dbTxPrefix)) + s.MountNative(http.MethodGet, "/db/users", dbUsersRangeHandler()) + s.MountNative(http.MethodPost, "/cache", cacheSetHandler()) + s.MountNative(http.MethodGet, "/cache-pipeline", cachePipelineHandler()) + s.MountNative(http.MethodGet, "/mc-multiget", mcMultiGetHandler()) +} + +// dbInsertHandler serves POST /db/insert — INSERT the request body into +// bench_writes. +func dbInsertHandler() fasthttp.RequestHandler { + return func(rc *fasthttp.RequestCtx) { + pg := mountedDrivers.pg + if pg == nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := pg.Exec(ctx, + "INSERT INTO bench_writes(payload) VALUES($1)", string(rc.PostBody()), + ); err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + rc.SetContentType("application/json") + rc.SetStatusCode(fasthttp.StatusOK) + _, _ = rc.Write(mustJSON(sessionResponse{OK: true})) + } +} + +// dbTxHandler serves POST /db/tx/user/:id — BEGIN; UPDATE score+1; COMMIT. +func dbTxHandler(prefix string) fasthttp.RequestHandler { + return func(rc *fasthttp.RequestCtx) { + pg := mountedDrivers.pg + if pg == nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + idStr := strings.TrimPrefix(string(rc.Path()), prefix) + if idStr == "" || strings.Contains(idStr, "/") { + rc.SetStatusCode(fasthttp.StatusBadRequest) + return + } + id, err := strconv.Atoi(idStr) + if err != nil { + rc.SetStatusCode(fasthttp.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tx, err := pg.Begin(ctx) + if err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + if _, err := tx.Exec(ctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(ctx) + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + if err := tx.Commit(ctx); err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + rc.SetContentType("application/json") + rc.SetStatusCode(fasthttp.StatusOK) + _, _ = rc.Write(mustJSON(sessionResponse{OK: true, Seq: id})) + } +} + +// dbUsersRangeHandler serves GET /db/users?limit=N — SELECT N rows -> JSON array. +func dbUsersRangeHandler() fasthttp.RequestHandler { + return func(rc *fasthttp.RequestCtx) { + pg := mountedDrivers.pg + if pg == nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + limit, err := strconv.Atoi(string(rc.QueryArgs().Peek("limit"))) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + rows, err := pg.Query(ctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit) + if err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var r userRow + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Score); err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + out = append(out, r) + } + if rows.Err() != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + rc.SetContentType("application/json") + rc.SetStatusCode(fasthttp.StatusOK) + _, _ = rc.Write(mustJSON(out)) + } +} + +// cacheSetHandler serves POST /cache — redis SET demo-write = request body. +func cacheSetHandler() fasthttp.RequestHandler { + return func(rc *fasthttp.RequestCtx) { + rdb := mountedDrivers.rdb + if rdb == nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := rdb.Set(ctx, "demo-write", rc.PostBody(), 0).Err(); err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + rc.SetContentType("application/json") + rc.SetStatusCode(fasthttp.StatusOK) + _, _ = rc.Write(mustJSON(sessionResponse{OK: true})) + } +} + +// cachePipelineHandler serves GET /cache-pipeline?n=N — pipeline N GETs of +// demo-key, returning the summed value length as seq. +func cachePipelineHandler() fasthttp.RequestHandler { + return func(rc *fasthttp.RequestCtx) { + rdb := mountedDrivers.rdb + if rdb == nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(string(rc.QueryArgs().Peek("n"))) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + pipe := rdb.Pipeline() + cmds := make([]*goredis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(ctx, "demo-key") + } + if _, err := pipe.Exec(ctx); err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + total += len(v) + } + rc.SetContentType("application/json") + rc.SetStatusCode(fasthttp.StatusOK) + _, _ = rc.Write(mustJSON(sessionResponse{OK: true, Seq: total})) + } +} + +// mcMultiGetHandler serves GET /mc-multiget?keys=N — GetMulti of N session keys. +func mcMultiGetHandler() fasthttp.RequestHandler { + return func(rc *fasthttp.RequestCtx) { + mc := mountedDrivers.mc + if mc == nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(string(rc.QueryArgs().Peek("keys"))) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := mc.GetMulti(keys) + if err != nil { + rc.SetStatusCode(fasthttp.StatusServiceUnavailable) + return + } + rc.SetContentType("application/json") + rc.SetStatusCode(fasthttp.StatusOK) + _, _ = rc.Write(mustJSON(sessionResponse{OK: true, Seq: len(items)})) + } } // dbUserHandler serves GET /db/user/:id — a single-row primary-key lookup diff --git a/servers/fiber/driver_handlers.go b/servers/fiber/driver_handlers.go index 8976490..898f07b 100644 --- a/servers/fiber/driver_handlers.go +++ b/servers/fiber/driver_handlers.go @@ -116,6 +116,144 @@ func mountDriverHandlers(app *fiber.App, ds *driverState) { return c.Send(item.Value) }) + // v1.5.4 driver-depth routes (idiomatic pgx/go-redis/gomemcache). + // Paths are /cache-pipeline and /mc-multiget (not /cache/pipeline or + // /mc/multi) so they do not collide with the /cache/:key and /mc/:key + // param routes above. + app.Post("/db/insert", func(c *fiber.Ctx) error { + if ds.pg == nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + ctx, cancel := context.WithTimeout(c.UserContext(), 5*time.Second) + defer cancel() + if _, err := ds.pg.Exec(ctx, + "INSERT INTO bench_writes(payload) VALUES($1)", string(c.Body()), + ); err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + c.Set("Content-Type", "application/json") + return c.Send(mustJSON(sessionResponse{OK: true})) + }) + + app.Post("/db/tx/user/:id", func(c *fiber.Ctx) error { + if ds.pg == nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + id, perr := strconv.Atoi(c.Params("id")) + if perr != nil { + return c.SendStatus(fiber.StatusBadRequest) + } + ctx, cancel := context.WithTimeout(c.UserContext(), 5*time.Second) + defer cancel() + tx, err := ds.pg.Begin(ctx) + if err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + if _, err := tx.Exec(ctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(ctx) + return c.SendStatus(fiber.StatusServiceUnavailable) + } + if err := tx.Commit(ctx); err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + c.Set("Content-Type", "application/json") + return c.Send(mustJSON(sessionResponse{OK: true, Seq: id})) + }) + + app.Get("/db/users", func(c *fiber.Ctx) error { + if ds.pg == nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + limit, err := strconv.Atoi(c.Query("limit")) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + ctx, cancel := context.WithTimeout(c.UserContext(), 5*time.Second) + defer cancel() + rows, err := ds.pg.Query(ctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit) + if err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var r userRow + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Score); err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + out = append(out, r) + } + if rows.Err() != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + c.Set("Content-Type", "application/json") + return c.Send(mustJSON(out)) + }) + + app.Post("/cache", func(c *fiber.Ctx) error { + if ds.rdb == nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + ctx, cancel := context.WithTimeout(c.UserContext(), 5*time.Second) + defer cancel() + if err := ds.rdb.Set(ctx, "demo-write", c.Body(), 0).Err(); err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + c.Set("Content-Type", "application/json") + return c.Send(mustJSON(sessionResponse{OK: true})) + }) + + app.Get("/cache-pipeline", func(c *fiber.Ctx) error { + if ds.rdb == nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + n, err := strconv.Atoi(c.Query("n")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + ctx, cancel := context.WithTimeout(c.UserContext(), 5*time.Second) + defer cancel() + pipe := ds.rdb.Pipeline() + cmds := make([]*goredis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(ctx, "demo-key") + } + if _, err := pipe.Exec(ctx); err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + total += len(v) + } + c.Set("Content-Type", "application/json") + return c.Send(mustJSON(sessionResponse{OK: true, Seq: total})) + }) + + app.Get("/mc-multiget", func(c *fiber.Ctx) error { + if ds.mc == nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + n, err := strconv.Atoi(c.Query("keys")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := ds.mc.GetMulti(keys) + if err != nil { + return c.SendStatus(fiber.StatusServiceUnavailable) + } + c.Set("Content-Type", "application/json") + return c.Send(mustJSON(sessionResponse{OK: true, Seq: len(items)})) + }) + app.Post("/session", func(c *fiber.Ctx) error { // The session round-trip is Redis-backed (parity with celeris's // redisstore). Without Redis there is nowhere to persist the blob, diff --git a/servers/gin/driver_handlers.go b/servers/gin/driver_handlers.go index 0072fca..b3e12ca 100644 --- a/servers/gin/driver_handlers.go +++ b/servers/gin/driver_handlers.go @@ -58,6 +58,166 @@ func mountDriverHandlers(r *gin.Engine) { r.GET("/cache/:key", st.handleCache) r.GET("/mc/:key", st.handleMC) r.POST("/session", st.handleSession) + + // v1.5.4 driver-depth routes (idiomatic pgx/go-redis/gomemcache). + r.POST("/db/insert", st.handleDBInsert) + r.POST("/db/tx/user/:id", st.handleDBTx) + r.GET("/db/users", st.handleDBUsersRange) + r.POST("/cache", st.handleCacheSet) + r.GET("/cache-pipeline", st.handleCachePipeline) + r.GET("/mc-multiget", st.handleMCMultiGet) +} + +// handleDBInsert serves POST /db/insert: INSERT the body into bench_writes. +func (st *driverState) handleDBInsert(c *gin.Context) { + if st.pg == nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + body, _ := c.GetRawData() + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + if _, err := st.pg.Exec(ctx, "INSERT INTO bench_writes(payload) VALUES($1)", string(body)); err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + c.JSON(http.StatusOK, sessionResponse{OK: true}) +} + +// handleDBTx serves POST /db/tx/user/:id: BEGIN; UPDATE score+1; COMMIT. +func (st *driverState) handleDBTx(c *gin.Context) { + if st.pg == nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + tx, err := st.pg.Begin(ctx) + if err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + if _, err := tx.Exec(ctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(ctx) + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + if err := tx.Commit(ctx); err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: id}) +} + +// handleDBUsersRange serves GET /db/users?limit=N: SELECT N rows -> JSON array. +func (st *driverState) handleDBUsersRange(c *gin.Context) { + if st.pg == nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + limit, err := strconv.Atoi(c.Query("limit")) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + rows, err := st.pg.Query(ctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit) + if err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var r userRow + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Score); err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + out = append(out, r) + } + if rows.Err() != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + c.JSON(http.StatusOK, out) +} + +// handleCacheSet serves POST /cache: SET demo-write = body. +func (st *driverState) handleCacheSet(c *gin.Context) { + if st.redis == nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + body, _ := c.GetRawData() + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + if err := st.redis.Set(ctx, "demo-write", body, 0).Err(); err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + c.JSON(http.StatusOK, sessionResponse{OK: true}) +} + +// handleCachePipeline serves GET /cache-pipeline?n=N: pipeline N GETs of demo-key. +func (st *driverState) handleCachePipeline(c *gin.Context) { + if st.redis == nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(c.Query("n")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + pipe := st.redis.Pipeline() + cmds := make([]*redis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(ctx, "demo-key") + } + if _, err := pipe.Exec(ctx); err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + total += len(v) + } + c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: total}) +} + +// handleMCMultiGet serves GET /mc-multiget?keys=N: GetMulti of N session keys. +func (st *driverState) handleMCMultiGet(c *gin.Context) { + if st.mc == nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(c.Query("keys")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := st.mc.GetMulti(keys) + if err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: len(items)}) } func (st *driverState) handleDBUser(c *gin.Context) { diff --git a/servers/hertz/driver_handlers.go b/servers/hertz/driver_handlers.go index 2f96ff8..b2aa0db 100644 --- a/servers/hertz/driver_handlers.go +++ b/servers/hertz/driver_handlers.go @@ -180,6 +180,166 @@ func mountDriverHandlers(h *server.Hertz, ds *driverState) { ctx.SetCookie(common.SessionCookieName, sid, 0, "/", "", protocol.CookieSameSiteDisabled, false, true) ctx.Data(consts.StatusOK, "application/json", mustJSON(sessionResponse{OK: true, Seq: newSeq})) }) + + mountDriverDepthHandlers(h, ds) +} + +// mountDriverDepthHandlers registers the v1.5.4 driver-depth routes +// (idiomatic pgx/go-redis/gomemcache). The paths are /cache-pipeline and +// /mc-multiget rather than /cache/pipeline and /mc/multi so they do not +// shadow the existing /cache/:key and /mc/:key param routes. +func mountDriverDepthHandlers(h *server.Hertz, ds *driverState) { + // POST /db/insert: INSERT the body into bench_writes. + h.POST("/db/insert", func(c context.Context, ctx *app.RequestContext) { + if ds.pg == nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + body := ctx.Request.Body() + qctx, cancel := context.WithTimeout(c, 5*time.Second) + defer cancel() + if _, err := ds.pg.Exec(qctx, "INSERT INTO bench_writes(payload) VALUES($1)", string(body)); err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + ctx.Data(consts.StatusOK, "application/json", mustJSON(sessionResponse{OK: true})) + }) + + // POST /db/tx/user/:id: BEGIN; UPDATE score+1; COMMIT. + h.POST("/db/tx/user/:id", func(c context.Context, ctx *app.RequestContext) { + if ds.pg == nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + id, perr := strconv.Atoi(ctx.Param("id")) + if perr != nil { + ctx.AbortWithStatus(consts.StatusBadRequest) + return + } + qctx, cancel := context.WithTimeout(c, 5*time.Second) + defer cancel() + tx, err := ds.pg.Begin(qctx) + if err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + if _, err := tx.Exec(qctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(qctx) + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + if err := tx.Commit(qctx); err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + ctx.Data(consts.StatusOK, "application/json", mustJSON(sessionResponse{OK: true, Seq: id})) + }) + + // GET /db/users?limit=N: SELECT N rows -> JSON array. + h.GET("/db/users", func(c context.Context, ctx *app.RequestContext) { + if ds.pg == nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + limit, err := strconv.Atoi(string(ctx.QueryArgs().Peek("limit"))) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + qctx, cancel := context.WithTimeout(c, 5*time.Second) + defer cancel() + rows, err := ds.pg.Query(qctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit) + if err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var r userRow + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Score); err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + out = append(out, r) + } + if rows.Err() != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + ctx.Data(consts.StatusOK, "application/json", mustJSON(out)) + }) + + // POST /cache: SET demo-write = body. + h.POST("/cache", func(c context.Context, ctx *app.RequestContext) { + if ds.rdb == nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + body := ctx.Request.Body() + qctx, cancel := context.WithTimeout(c, 5*time.Second) + defer cancel() + if err := ds.rdb.Set(qctx, "demo-write", body, 0).Err(); err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + ctx.Data(consts.StatusOK, "application/json", mustJSON(sessionResponse{OK: true})) + }) + + // GET /cache-pipeline?n=N: pipeline N GETs of demo-key. + h.GET("/cache-pipeline", func(c context.Context, ctx *app.RequestContext) { + if ds.rdb == nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(string(ctx.QueryArgs().Peek("n"))) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + qctx, cancel := context.WithTimeout(c, 5*time.Second) + defer cancel() + pipe := ds.rdb.Pipeline() + cmds := make([]*goredis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(qctx, "demo-key") + } + if _, err := pipe.Exec(qctx); err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + total += len(v) + } + ctx.Data(consts.StatusOK, "application/json", mustJSON(sessionResponse{OK: true, Seq: total})) + }) + + // GET /mc-multiget?keys=N: GetMulti of N session keys. + h.GET("/mc-multiget", func(_ context.Context, ctx *app.RequestContext) { + if ds.mc == nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(string(ctx.QueryArgs().Peek("keys"))) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := ds.mc.GetMulti(keys) + if err != nil { + ctx.AbortWithStatus(consts.StatusServiceUnavailable) + return + } + ctx.Data(consts.StatusOK, "application/json", mustJSON(sessionResponse{OK: true, Seq: len(items)})) + }) } // shutdownDriverState closes any open backend clients so a re-spawned diff --git a/servers/iris/driver_handlers.go b/servers/iris/driver_handlers.go index d39dffd..8ed6a22 100644 --- a/servers/iris/driver_handlers.go +++ b/servers/iris/driver_handlers.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "strconv" "time" "github.com/bradfitz/gomemcache/memcache" @@ -40,7 +41,7 @@ type driverClients struct { const ( envPGDSN = "PROBATORIUM_PG_DSN" envRedisAddr = "PROBATORIUM_REDIS_ADDR" - envMCAddr = "PROBATORIUM_MC_ADDR" + envMCAddr = "PROBATORIUM_MEMCACHED_ADDR" ) // userRow mirrors the seeded users table row (id, name, email, score). @@ -156,6 +157,172 @@ func mountDriverHandlers(app *irisv12.Application, dc *driverClients) { _, _ = c.Write(it.Value) }) + // v1.5.4 driver-depth routes (writes / transaction / range / pipeline / + // multiget) using the same idiomatic pgx/go-redis/gomemcache ops as the + // other adapters. The /cache-pipeline and /mc-multiget paths are flat + // (not /cache/pipeline) so they don't collide with the /cache/{key} and + // /mc/{key} param routes above. + + // Postgres write: POST /db/insert — INSERT the request body into bench_writes. + app.Post("/db/insert", func(c irisv12.Context) { + if dc.pg == nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + body, _ := io.ReadAll(c.Request().Body) + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + if _, err := dc.pg.Exec(ctx, + "INSERT INTO bench_writes(payload) VALUES($1)", string(body), + ); err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + c.ContentType("application/json") + _ = c.JSON(sessionResponse{OK: true}) + }) + + // Postgres transaction: POST /db/tx/user/{id} — BEGIN; UPDATE score+1; COMMIT. + app.Post("/db/tx/user/{id:int}", func(c irisv12.Context) { + if dc.pg == nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + id, err := c.Params().GetInt("id") + if err != nil { + c.StopWithStatus(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + tx, err := dc.pg.Begin(ctx) + if err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + if _, err := tx.Exec(ctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(ctx) + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + if err := tx.Commit(ctx); err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + c.ContentType("application/json") + _ = c.JSON(sessionResponse{OK: true, Seq: id}) + }) + + // Postgres range read: GET /db/users?limit=N — SELECT N rows -> JSON array. + app.Get("/db/users", func(c irisv12.Context) { + if dc.pg == nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + limit := c.URLParamIntDefault("limit", 50) + if limit <= 0 || limit > 1000 { + limit = 50 + } + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + rows, err := dc.pg.Query(ctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit) + if err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var r userRow + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Score); err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + out = append(out, r) + } + if rows.Err() != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + c.ContentType("application/json") + _ = c.JSON(out) + }) + + // Redis write: POST /cache — SET demo-write = request body, no expiry. + app.Post("/cache", func(c irisv12.Context) { + if dc.redis == nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + body, _ := io.ReadAll(c.Request().Body) + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + if err := dc.redis.Set(ctx, "demo-write", body, 0).Err(); err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + c.ContentType("application/json") + _ = c.JSON(sessionResponse{OK: true}) + }) + + // Redis pipeline: GET /cache-pipeline?n=N — pipeline N GETs of demo-key. + app.Get("/cache-pipeline", func(c irisv12.Context) { + if dc.redis == nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + n := c.URLParamIntDefault("n", 10) + if n <= 0 || n > 100 { + n = 10 + } + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + pipe := dc.redis.Pipeline() + cmds := make([]*redis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(ctx, "demo-key") + } + if _, err := pipe.Exec(ctx); err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + total += len(v) + } + c.ContentType("application/json") + _ = c.JSON(sessionResponse{OK: true, Seq: total}) + }) + + // Memcached multiget: GET /mc-multiget?keys=N — GetMulti of N session keys. + app.Get("/mc-multiget", func(c irisv12.Context) { + if dc.mc == nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + n := c.URLParamIntDefault("keys", 10) + if n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := dc.mc.GetMulti(keys) + if err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + c.ContentType("application/json") + _ = c.JSON(sessionResponse{OK: true, Seq: len(items)}) + }) + // Session: POST /session — iris's own session middleware, started // inline so the hit counter only fires on this route. app.Post("/session", func(c irisv12.Context) { diff --git a/servers/stdhttp/driver_handlers.go b/servers/stdhttp/driver_handlers.go index c6ddf01..b8e31d3 100644 --- a/servers/stdhttp/driver_handlers.go +++ b/servers/stdhttp/driver_handlers.go @@ -29,6 +29,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "io" "net/http" "os" "strconv" @@ -130,6 +131,18 @@ func mountDriverHandlers(mux *http.ServeMux) { mux.HandleFunc("GET /cache/{key}", c.cacheHandler) mux.HandleFunc("GET /mc/{key}", c.mcHandler) mux.HandleFunc("POST /session", c.sessionHandler) + + // v1.5.4 driver-depth routes (writes / transaction / range / pipeline / + // multiget), expressed with the same idiomatic pgx/go-redis/gomemcache + // APIs as the reads above. The /cache-pipeline and /mc-multiget paths are + // deliberately distinct from the /cache/{key} and /mc/{key} param routes + // so they do not collide on ServeMux's pattern matching. + mux.HandleFunc("POST /db/insert", c.dbInsertHandler) + mux.HandleFunc("POST /db/tx/user/{id}", c.dbTxHandler) + mux.HandleFunc("GET /db/users", c.dbUsersRangeHandler) + mux.HandleFunc("POST /cache", c.cacheSetHandler) + mux.HandleFunc("GET /cache-pipeline", c.cachePipelineHandler) + mux.HandleFunc("GET /mc-multiget", c.mcMultiGetHandler) } // dbUserHandler serves GET /db/user/{id}: SELECT id,name,email,score and @@ -255,6 +268,180 @@ func (c *driverClients) sessionHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(sessionResponse{OK: true, Seq: seq}) } +// dbInsertHandler serves POST /db/insert: INSERT the request body into the +// bench_writes table. nil pool / exec error -> 503. +func (c *driverClients) dbInsertHandler(w http.ResponseWriter, r *http.Request) { + if c.pg == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + body, _ := io.ReadAll(r.Body) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if _, err := c.pg.Exec(ctx, + "INSERT INTO bench_writes(payload) VALUES($1)", string(body), + ); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(sessionResponse{OK: true}) +} + +// dbTxHandler serves POST /db/tx/user/{id}: BEGIN; UPDATE score+1; COMMIT. +// nil pool / error -> 503; bad id -> 400. +func (c *driverClients) dbTxHandler(w http.ResponseWriter, r *http.Request) { + if c.pg == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + tx, err := c.pg.Begin(ctx) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if _, err := tx.Exec(ctx, "UPDATE users SET score=score+1 WHERE id=$1", id); err != nil { + _ = tx.Rollback(ctx) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if err := tx.Commit(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(sessionResponse{OK: true, Seq: id}) +} + +// dbUsersRangeHandler serves GET /db/users?limit=N: SELECT the first N rows +// and JSON-encode the array. limit defaults to 50, clamped to 1..1000. +func (c *driverClients) dbUsersRangeHandler(w http.ResponseWriter, r *http.Request) { + if c.pg == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + limit, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil || limit <= 0 || limit > 1000 { + limit = 50 + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + rows, err := c.pg.Query(ctx, + "SELECT id, name, email, score FROM users WHERE id BETWEEN 1 AND $1 ORDER BY id", limit, + ) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + defer rows.Close() + out := make([]userRow, 0, limit) + for rows.Next() { + var row userRow + if err := rows.Scan(&row.ID, &row.Name, &row.Email, &row.Score); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + out = append(out, row) + } + if rows.Err() != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(out) +} + +// cacheSetHandler serves POST /cache: Redis SET demo-write = request body, +// no expiry. nil client / error -> 503. +func (c *driverClients) cacheSetHandler(w http.ResponseWriter, r *http.Request) { + if c.redis == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + body, _ := io.ReadAll(r.Body) + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + if err := c.redis.Set(ctx, "demo-write", body, 0).Err(); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(sessionResponse{OK: true}) +} + +// cachePipelineHandler serves GET /cache-pipeline?n=N: pipeline N GETs of +// demo-key in one round-trip and sum their byte lengths into seq. n defaults +// to 10, clamped to 1..100. +func (c *driverClients) cachePipelineHandler(w http.ResponseWriter, r *http.Request) { + if c.redis == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(r.URL.Query().Get("n")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + pipe := c.redis.Pipeline() + cmds := make([]*redis.StringCmd, n) + for i := 0; i < n; i++ { + cmds[i] = pipe.Get(ctx, "demo-key") + } + if _, err := pipe.Exec(ctx); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + total := 0 + for _, cmd := range cmds { + v, err := cmd.Bytes() + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + total += len(v) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(sessionResponse{OK: true, Seq: total}) +} + +// mcMultiGetHandler serves GET /mc-multiget?keys=N: GetMulti of N seeded +// user::session keys in one batch. keys defaults to 10, clamped to 1..100. +func (c *driverClients) mcMultiGetHandler(w http.ResponseWriter, r *http.Request) { + if c.mc == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + n, err := strconv.Atoi(r.URL.Query().Get("keys")) + if err != nil || n <= 0 || n > 100 { + n = 10 + } + keys := make([]string, n) + for i := 0; i < n; i++ { + keys[i] = "user:" + strconv.Itoa(i+1) + ":session" + } + items, err := c.mc.GetMulti(keys) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(sessionResponse{OK: true, Seq: len(items)}) +} + // sessionID returns the session id from the pmsid cookie, or a freshly // generated one (with fresh=true so the caller emits a Set-Cookie). func sessionID(r *http.Request) (id string, fresh bool) { From 1137fdf3d1b82509fdaa3ef30470d9c73c0245f7 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Sun, 21 Jun 2026 19:45:08 +0200 Subject: [PATCH 04/11] bench(v1.5.4): native WS+SSE for axum/hono/starlette competitors (W4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the WS/SSE grid beyond celeris+gorilla. Each adapter serves the fixed wire contract (GET /ws ?mode=ws-echo|ws-large-echo|ws-hub, GET /events text/event-stream, 1ms publish, "payload"/"hello"): - axum (Rust): axum::extract::ws + response::sse; a single broadcast tick drives both hub fan-out and SSE. serve_h1 gains .with_upgrades() (mandatory — without it hyper writes 101 then drops the socket); h2c serve path untouched. - hono (Bun): Bun.serve native websocket + SSE ReadableStream on the h1 branch; one 1ms ticker, drop-on-backpressure fan-out. - starlette (Python): WebSocketRoute + StreamingResponse; the two 1ms asyncio tickers start per worker via a lifespan; WS rides uvicorn[standard]'s bundled websockets (no new dep). Flip Capabilities{WS,SSE} on the three h1 columns. featureSetFor already projects them and streaming gates on fs.HTTP1, so the -h2 siblings stay out of the streaming grid. All three were build- and runtime-smoke- verified locally (101 upgrade, echo round-trip, hub broadcast, SSE frame, 256 KiB large-echo). --- servers/axum/Cargo.toml | 9 +- servers/axum/src/main.rs | 33 +++++- servers/axum/src/streaming.rs | 161 +++++++++++++++++++++++++++ servers/hono/src/server.ts | 24 +++- servers/hono/src/streaming.ts | 138 +++++++++++++++++++++++ servers/servers.go | 14 ++- servers/starlette/app/server.py | 13 ++- servers/starlette/app/streaming.py | 169 +++++++++++++++++++++++++++++ servers/starlette/pyproject.toml | 3 + 9 files changed, 553 insertions(+), 11 deletions(-) create mode 100644 servers/axum/src/streaming.rs create mode 100644 servers/hono/src/streaming.ts create mode 100644 servers/starlette/app/streaming.py diff --git a/servers/axum/Cargo.toml b/servers/axum/Cargo.toml index 077ccfe..424ea1c 100644 --- a/servers/axum/Cargo.toml +++ b/servers/axum/Cargo.toml @@ -34,8 +34,13 @@ name = "probatorium-axum-server" path = "src/main.rs" [dependencies] -# axum — tower-stack HTTP framework. Pulls hyper transitively. -axum = ">=0.7" +# axum — tower-stack HTTP framework. Pulls hyper transitively. The "ws" +# feature pulls the WebSocketUpgrade extractor + tungstenite codec for the +# /ws route; SSE (Sse/Event/KeepAlive) is in axum's default surface. +axum = { version = ">=0.7", features = ["ws"] } +# futures-util — Stream/StreamExt + stream::unfold for the SSE response +# stream, and the Sink/Stream traits the axum WebSocket is built on. +futures-util = ">=0.3" # hyper — declared explicitly so the h2c engine can drive each accepted # TCP connection with hyper's HTTP/2 prior-knowledge server builder # (hyper::server::conn::http2::Builder). The "http2" feature is what diff --git a/servers/axum/src/main.rs b/servers/axum/src/main.rs index b0058f1..5abe1e1 100644 --- a/servers/axum/src/main.rs +++ b/servers/axum/src/main.rs @@ -40,6 +40,7 @@ // well below the spawn() SIGKILL fallback in servers/start.go. mod payload; +mod streaming; use std::net::SocketAddr; use std::process::ExitCode; @@ -79,6 +80,15 @@ async fn main() -> ExitCode { }; let bind = parse_bind_arg().unwrap_or_else(|| "127.0.0.1:8080".to_string()); + // Shared streaming state (the broadcast tick) + its 1ms publisher, + // spawned before serving so the first subscriber catches the next pulse. + // The static handlers are state-free; with_state returns a plain Router, + // so serve_h1/serve_h2c signatures are unchanged. The WS/SSE routes ride + // h1 only — they exist on both listeners, but the h2c column never hits + // them (see scenario applicability in servers/servers.go). + let state = streaming::AppState::new(); + state.spawn_publisher(); + let app = Router::new() .route("/", get(root)) .route("/json", get(json_static)) @@ -89,7 +99,10 @@ async fn main() -> ExitCode { // axum 0.8+ uses `{id}` capture groups; older `:id` is rejected // at router-build time. See axum CHANGELOG 0.8 for the rationale. .route("/users/{id}", get(users_id)) - .route("/upload", post(upload)); + .route("/upload", post(upload)) + .route("/ws", get(streaming::ws_handler)) + .route("/events", get(streaming::sse_handler)) + .with_state(state); let addr: SocketAddr = match bind.parse() { Ok(a) => a, @@ -140,8 +153,22 @@ async fn main() -> ExitCode { async fn serve_h1(listener: TcpListener, app: Router) -> std::io::Result<()> { let svc = TowerToHyperService::new(app); serve_loop(listener, move |io, graceful| { - let conn = http1::Builder::new().serve_connection(io, svc.clone()); - graceful.watch(conn) + // with_upgrades() is mandatory for the /ws route: it hands the + // post-101 socket back to axum's on_upgrade future. Without it hyper + // writes the 101 and then drops the connection, so the WebSocket + // closes the instant it opens. The h2c path needs no equivalent — + // WS/SSE are h1-only and the h2c column never drives them. + // + // hyper-util's GracefulConnection is NOT implemented for the h1 + // UpgradeableConnection, so we cannot route it through + // graceful.watch like the plain connections. We bypass the watcher + // and own the conn future directly; the accept loop's 3s drain cap + // (serve_loop) still bounds shutdown, and these connections are the + // long-lived WS/SSE streams that are force-dropped on SIGTERM anyway. + let _ = graceful; + http1::Builder::new() + .serve_connection(io, svc.clone()) + .with_upgrades() }) .await } diff --git a/servers/axum/src/streaming.rs b/servers/axum/src/streaming.rs new file mode 100644 index 0000000..9888cde --- /dev/null +++ b/servers/axum/src/streaming.rs @@ -0,0 +1,161 @@ +// probatorium axum adapter — WS/SSE streaming surface. +// +// Mirrors the gorilla_ws reference (servers/gorilla_ws/server.go) so the +// celeris streaming cell-columns have a like-for-like tower-stack baseline. +// Two routes, both HTTP/1.1 only (the WS upgrade and the SSE long-poll both +// ride h1; the h2c serve path never exercises them): +// +// GET /ws — RFC6455 upgrade; ?mode= selects echo / large-echo / hub. +// GET /events — SSE fan-out; every subscriber streams the published frame. +// +// Fan-out is a single broadcast "tick": one task pulses `()` every 1ms and +// every hub socket / SSE subscriber emits its own frame on each tick. This +// replaces the gorilla reference's per-broker connection-set + RWMutex with +// tokio's broadcast channel — slow consumers see `Lagged` and skip, which is +// exactly the drop-rather-than-block policy the reference's non-blocking +// channel send implements. + +use std::collections::HashMap; +use std::time::Duration; + +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Query, State}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::Response; +use futures_util::stream::{self, Stream, StreamExt}; +use tokio::sync::broadcast; + +// ?mode= selectors — mirror scenarios.StreamMode* and the gorilla reference +// so the loadgen client drives every adapter identically. +const STREAM_MODE_WS_HUB: &str = "ws-hub"; +const STREAM_MODE_WS_LARGE_ECHO: &str = "ws-large-echo"; + +const BROADCAST_PAYLOAD: &str = "payload"; +const SSE_EVENT_DATA: &str = "hello"; + +const STREAM_PUBLISH_INTERVAL: Duration = Duration::from_millis(1); + +// 1 MiB read limit for the large-echo mode — holds the 64 KiB large-echo +// payload with headroom, matching the gorilla reference's SetReadLimit. +const LARGE_ECHO_READ_LIMIT: usize = 1 << 20; + +// Tick-channel capacity. A subscriber that falls this far behind the 1ms +// publisher sees Lagged and skips ahead — the drop policy. Sized generously +// so only genuinely stalled consumers ever lag. +const TICK_CHANNEL_CAPACITY: usize = 1024; + +// AppState carries the shared broadcast tick. Cloning a Sender is cheap +// (Arc bump) and every handler subscribes off it, so the static handlers +// stay state-free while the streaming handlers extract this. +#[derive(Clone)] +pub struct AppState { + tick: broadcast::Sender<()>, +} + +impl AppState { + pub fn new() -> Self { + let (tick, _) = broadcast::channel(TICK_CHANNEL_CAPACITY); + Self { tick } + } + + // spawn_publisher starts the single 1ms pulse that drives every hub + // socket and SSE subscriber. send() errors only when there are zero + // receivers; that is the steady idle state, so the error is discarded. + pub fn spawn_publisher(&self) { + let tick = self.tick.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(STREAM_PUBLISH_INTERVAL); + loop { + interval.tick().await; + let _ = tick.send(()); + } + }); + } +} + +// ws_handler upgrades the connection and dispatches on ?mode=. large-echo +// raises the per-message read limit before the upgrade is negotiated. +pub async fn ws_handler( + ws: WebSocketUpgrade, + Query(params): Query>, + State(state): State, +) -> Response { + let mode = params.get("mode").cloned(); + match mode.as_deref() { + Some(STREAM_MODE_WS_HUB) => { + let rx = state.tick.subscribe(); + ws.on_upgrade(move |socket| ws_hub(socket, rx)) + } + Some(STREAM_MODE_WS_LARGE_ECHO) => ws + .max_message_size(LARGE_ECHO_READ_LIMIT) + .max_frame_size(LARGE_ECHO_READ_LIMIT) + .on_upgrade(ws_echo), + _ => ws.on_upgrade(ws_echo), + } +} + +// ws_echo reflects every received frame back verbatim (same opcode). Any +// read or write error ends the loop and drops the socket. +async fn ws_echo(mut socket: WebSocket) { + while let Some(Ok(msg)) = socket.recv().await { + // Stop on control-close; only data frames are echoed. recv() already + // answers Ping with Pong internally, so we just forward the rest. + if matches!(msg, Message::Close(_)) { + return; + } + if socket.send(msg).await.is_err() { + return; + } + } +} + +// ws_hub is a pure broadcast RECEIVER: it never echoes. On each tick it +// pushes the TEXT "payload" frame, while concurrently draining inbound +// frames (control frames, eventual close) so the peer's reads are serviced +// and a close is noticed promptly. Lagged ticks are skipped (drop policy). +async fn ws_hub(mut socket: WebSocket, mut rx: broadcast::Receiver<()>) { + loop { + tokio::select! { + tick = rx.recv() => match tick { + Ok(()) => { + if socket.send(Message::text(BROADCAST_PAYLOAD)).await.is_err() { + return; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return, + }, + inbound = socket.recv() => match inbound { + Some(Ok(_)) => {} // drain; receivers never echo + _ => return, // read error or peer gone + }, + } + } +} + +// sse_handler answers 200 with the event-stream content type and streams a +// `data: hello\n\n` frame on every tick. A lagging subscriber drops the +// missed ticks (filter_map skips Lagged) rather than back-pressuring the +// publisher. The stream ends when the client disconnects: axum drops the +// response future, which drops the receiver. +pub async fn sse_handler( + State(state): State, +) -> Sse>> { + let rx = state.tick.subscribe(); + let stream = stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(()) => { + let event = Event::default().data(SSE_EVENT_DATA); + return Some((Ok(event), rx)); + } + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }); + // KeepAlive is harmless here (the 1ms publisher already keeps the + // connection hot) but matches axum's idiomatic SSE setup and guards the + // idle window before the first subscriber tick. + Sse::new(stream.boxed()).keep_alive(KeepAlive::default()) +} diff --git a/servers/hono/src/server.ts b/servers/hono/src/server.ts index 50fc394..ee5f721 100644 --- a/servers/hono/src/server.ts +++ b/servers/hono/src/server.ts @@ -33,6 +33,12 @@ import { json64KPayload, } from "./payload"; import { serveH2C } from "./h2c"; +import { + websocket, + handleStreaming, + startBroadcast, + stopBroadcast, +} from "./streaming"; const HELLO = new TextEncoder().encode("Hello, World!"); const JSON_HELLO = new TextEncoder().encode('{"message":"Hello, World!"}'); @@ -137,9 +143,23 @@ if (engine === "h2c") { // multiple Bun.serve workers if a future operator launches more // than one process — harmless on a single-process bench. reusePort: true, - fetch: app.fetch, + // WS frame size is capped to the large-echo ceiling (1 MiB) by + // maxPayloadLength inside the websocket handler; static routes unaffected. + websocket, + fetch: (req, srv) => { + // WS/SSE ride this same H1 listener: intercept /ws (upgrade) and + // /events (SSE) first, else delegate to the Hono route table. A + // successful upgrade returns undefined (Bun owns the socket). + const streamed = handleStreaming(req, srv); + if (streamed !== null) return streamed ?? undefined; + return app.fetch(req, srv); + }, }); + // Start the single 1ms ticker driving the WS hub broadcast + SSE publish + // fan-out once the listener is up. + startBroadcast(); + // Bun.serve.port is the resolved port (kernel-assigned when the // caller passed 0). Print the ready line in the exact shape every // other adapter uses so the runner's TCP-probe loop can attach. @@ -147,6 +167,8 @@ if (engine === "h2c") { const shutdown = (signal: string): void => { console.log(`hono: received ${signal}, shutting down`); + // Halt the 1ms broadcast/publish ticker before tearing the listener down. + stopBroadcast(); // stop(true) closes idle keep-alives immediately; in-flight // requests still get to drain. Bun resolves the returned promise // when the listener is fully torn down, but we don't await it — diff --git a/servers/hono/src/streaming.ts b/servers/hono/src/streaming.ts new file mode 100644 index 0000000..0c55c3d --- /dev/null +++ b/servers/hono/src/streaming.ts @@ -0,0 +1,138 @@ +// WS/SSE streaming surface for the Bun (h1) path — the like-for-like +// counterpart to the gorilla_ws reference adapter (servers/gorilla_ws). +// +// Two routes ride the existing Bun.serve HTTP/1.1 listener: +// - GET /ws WebSocket; ?mode= selects echo / large-echo / hub-receiver +// - GET /events SSE fan-out; every subscriber joins the broker, a +// background publisher pushes a steady event stream +// +// Both are H1-only by design (the WS upgrade + SSE long-poll ride HTTP/1.1); +// this module is never wired into the h2c (node:http2) branch. + +import type { ServerWebSocket, Server, WebSocketHandler } from "bun"; + +// ?mode= selectors — mirror the gorilla_ws reference + scenarios.StreamMode* +// so the loadgen client drives every adapter identically. +const STREAM_MODE_WS_HUB = "ws-hub"; +const STREAM_MODE_WS_LARGE_ECHO = "ws-large-echo"; + +// 1 MiB read limit for large-echo; holds the 64 KiB large-echo payload. +export const LARGE_ECHO_MAX_PAYLOAD = 1 << 20; + +const BROADCAST_PAYLOAD = "payload"; +const SSE_EVENT_DATA = "hello"; +const STREAM_PUBLISH_INTERVAL_MS = 1; + +// Per-socket data threaded through server.upgrade → websocket handlers; the +// mode chosen at upgrade time decides receiver-vs-echo behaviour in message(). +interface WSData { + mode: string; +} + +type HonoWebSocket = ServerWebSocket; + +// --- WebSocket hub (the naive connection-set the celeris Hub replaces) --- + +// Pure broadcast receivers; the ticker fans BROADCAST_PAYLOAD to each. Echo +// sockets never join — they reply inline in message() and are not iterated. +const hub = new Set(); + +// Bun's native websocket handler. open() decides registration by mode; +// message() echoes for the echo modes; close() always leaves the hub. +export const websocket: WebSocketHandler = { + maxPayloadLength: LARGE_ECHO_MAX_PAYLOAD, + open(ws: HonoWebSocket): void { + if (ws.data.mode === STREAM_MODE_WS_HUB) hub.add(ws); + }, + message(ws: HonoWebSocket, message: string | Buffer): void { + // Hub receivers drain only; everything else echoes verbatim. + if (ws.data.mode === STREAM_MODE_WS_HUB) return; + ws.send(message); + }, + close(ws: HonoWebSocket): void { + hub.delete(ws); + }, +}; + +// --- SSE broker (the publish-to-N-subscribers fan-out the celeris Broker replaces) --- + +// Pre-encode the SSE wire frame once: "data: hello\n\n". +const SSE_FRAME = new TextEncoder().encode(`data: ${SSE_EVENT_DATA}\n\n`); + +// Subscribed response controllers. A slow/closed controller is dropped on the +// failing enqueue rather than blocking the shared publish loop. +const subscribers = new Set>(); + +// --- background tickers --- + +let broadcastTimer: ReturnType | undefined; + +// startBroadcast launches the single 1ms loop that drives both fan-outs: the +// WS hub broadcast and the SSE publish share one tick so the wire cadence +// matches the reference's two independent tickers without doubling timers. +export function startBroadcast(): void { + if (broadcastTimer !== undefined) return; + broadcastTimer = setInterval(() => { + for (const ws of hub) ws.send(BROADCAST_PAYLOAD); + for (const controller of subscribers) { + try { + controller.enqueue(SSE_FRAME); + } catch { + // closed or backpressured controller: drop rather than block the loop. + subscribers.delete(controller); + } + } + }, STREAM_PUBLISH_INTERVAL_MS); +} + +export function stopBroadcast(): void { + if (broadcastTimer !== undefined) { + clearInterval(broadcastTimer); + broadcastTimer = undefined; + } +} + +// --- fetch interception --- + +const SSE_HEADERS: Record = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", +}; + +// handleStreaming intercepts the two streaming routes. Returns a Response for +// /events, undefined for a successful /ws upgrade (Bun owns the socket), and +// null for everything else so the caller falls through to app.fetch. +export function handleStreaming( + req: Request, + server: Server, +): Response | undefined | null { + const url = new URL(req.url); + + if (url.pathname === "/ws") { + const mode = url.searchParams.get("mode") ?? ""; + // upgrade() hands the socket to the websocket handler on success; returning + // undefined tells Bun the response is owned by the upgraded connection. + if (server.upgrade(req, { data: { mode } })) return undefined; + return new Response("expected websocket upgrade", { status: 426 }); + } + + if (url.pathname === "/events") { + // Capture the controller so cancel() can deregister this exact subscriber + // when the client disconnects (the explicit-unsubscribe path); the publish + // loop's drop-on-enqueue is the backstop for controllers that error first. + let ctrl: ReadableStreamDefaultController | undefined; + const stream = new ReadableStream({ + start(controller): void { + ctrl = controller; + subscribers.add(controller); + }, + cancel(): void { + if (ctrl !== undefined) subscribers.delete(ctrl); + }, + }); + return new Response(stream, { headers: SSE_HEADERS }); + } + + return null; +} diff --git a/servers/servers.go b/servers/servers.go index b72d157..2bb74ba 100644 --- a/servers/servers.go +++ b/servers/servers.go @@ -327,7 +327,10 @@ var Registry = map[string]Adapter{ }, RunCmd: "{bin} -bind {bind}", }, - Capabilities: Capabilities{Static: true}, + // v1.5.4: native WS+SSE streaming (axum::extract::ws + sse) — the + // /ws (?mode=) + /events routes ride the h1 listener; the h2c + // column never drives them (streaming gates on fs.HTTP1). + Capabilities: Capabilities{Static: true, WS: true, SSE: true}, }, // axum-h2 — the same servers/axum binary in prior-knowledge h2c mode // (-engine h2c serves HTTP/2 cleartext only, refusing H1 like @@ -622,6 +625,10 @@ var Registry = map[string]Adapter{ Lang: "bun", RunCmd: "{name} -bind {bind}", }, + // v1.5.4: native WS+SSE streaming (Bun.serve websocket + SSE + // ReadableStream) on the h1 path; the hono-h2 column (node:http2 + // bridge) keeps no streaming — scenarios gate on fs.HTTP1. + Capabilities: Capabilities{Static: true, WS: true, SSE: true}, }, // hono-h2 — prior-knowledge h2c via node:http2.createServer bridged to // Hono's app.fetch; -engine h2c selects it. Shares the competitors/hono @@ -674,7 +681,10 @@ var Registry = map[string]Adapter{ Lang: "python", RunCmd: "{bench}/competitors/{name}/server -bind {bind}", }, - Capabilities: Capabilities{Static: true}, + // v1.5.4: native WS+SSE streaming (Starlette WebSocketRoute + + // StreamingResponse) on the uvicorn h1 path; the starlette-h2 + // (hypercorn) column drives no streaming — gates on fs.HTTP1. + Capabilities: Capabilities{Static: true, WS: true, SSE: true}, }, // bunraw — raw Bun.serve baseline, no framework (the bun analogue of hyper). "bunraw": { diff --git a/servers/starlette/app/server.py b/servers/starlette/app/server.py index 69925ef..2f61a5d 100644 --- a/servers/starlette/app/server.py +++ b/servers/starlette/app/server.py @@ -63,7 +63,7 @@ from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response -from starlette.routing import Route +from starlette.routing import Route, WebSocketRoute from .payload import ( JSON_1K_PAYLOAD, @@ -71,6 +71,7 @@ JSON_16K_PAYLOAD, JSON_64K_PAYLOAD, ) +from .streaming import lifespan, sse_endpoint, ws_endpoint # Static byte payloads. Hoisted to module scope so each request reuses the # same immutable bytes object — no per-request allocation, mirrors the Go @@ -140,7 +141,10 @@ async def upload(request: Request) -> Response: # Explicit route table — the plain-Starlette analogue of FastAPI's # decorators. Order is irrelevant (Starlette matches by exact path then -# parametrised path), but kept in contract order for readability. +# parametrised path), but kept in contract order for readability. The /ws and +# /events routes ride the same H1 listener (streaming is H1-only); `lifespan` +# launches the 1ms WS-broadcast + SSE-publish tickers per worker on the live +# event loop. app = Starlette( routes=[ Route("/", root, methods=["GET"]), @@ -151,7 +155,10 @@ async def upload(request: Request) -> Response: Route("/json-64k", json_64k, methods=["GET"]), Route("/users/{user_id}", users, methods=["GET"]), Route("/upload", upload, methods=["POST"]), - ] + WebSocketRoute("/ws", ws_endpoint), + Route("/events", sse_endpoint, methods=["GET"]), + ], + lifespan=lifespan, ) diff --git a/servers/starlette/app/streaming.py b/servers/starlette/app/streaming.py new file mode 100644 index 0000000..4eea33a --- /dev/null +++ b/servers/starlette/app/streaming.py @@ -0,0 +1,169 @@ +"""WebSocket + SSE streaming surface for the Starlette adapter. + +This is the Python/ASGI counterpart to the gorilla_ws reference adapter +(``servers/gorilla_ws/server.go``): the same connection-set broadcast Hub +and publish-to-N-subscribers SSE fan-out, so the streaming cell-columns pair +a Starlette column against the Go baseline on a like-for-like wire contract. + +Both surfaces ride HTTP/1.1 (the WS upgrade and the SSE long-poll are H1-only), +so they mount on the uvicorn ``_serve_h1`` fast path. The h2c/hypercorn path is +left untouched — streaming scenarios gate on ``fs.HTTP1`` and skip H2C-only. + +Wire constants are byte-identical to the reference: WS hub broadcasts the TEXT +frame ``"payload"``; SSE pushes the frame ``data: hello\\n\\n``; both tick at 1ms. + +The two 1ms tickers must run on the live event loop, so they are launched from +a Starlette ``lifespan`` async context manager (one publisher per worker) rather +than at import time. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import StreamingResponse +from starlette.websockets import WebSocket, WebSocketDisconnect + +# ?mode= selectors — mirror scenarios.StreamMode* and the gorilla_ws adapter so +# the loadgen client drives both adapters identically. +_MODE_WS_HUB = "ws-hub" +_MODE_WS_LARGE_ECHO = "ws-large-echo" + +_BROADCAST_PAYLOAD = "payload" +# Pre-format the SSE wire frame once: "data: hello\n\n". +_SSE_FRAME = b"data: hello\n\n" + +_PUBLISH_INTERVAL = 0.001 # 1ms, matches streamPublishInterval + +# Bounded per-subscriber queue: a slow SSE consumer drops events rather than +# wedging the publish loop (the drop policy the gorilla_ws broker also uses). +_SSE_QUEUE_MAXSIZE = 1024 + + +# --- WebSocket: broadcast hub (the pattern the celeris Hub replaces) --- + +# A plain set guarded by the GIL — every ASGI handler runs on the single event +# loop, so the broadcast loop and the (un)register sites never preempt mid-op. +_hub: set[WebSocket] = set() + + +async def ws_endpoint(websocket: WebSocket) -> None: + await websocket.accept() + mode = websocket.query_params.get("mode", "ws-echo") + if mode == _MODE_WS_HUB: + await _ws_hub_receiver(websocket) + else: + # ws-echo and ws-large-echo are identical: the websockets default + # max_size is 1 MiB, which already bounds the 64 KiB large-echo + # payload, so no raised read limit is needed. + await _ws_echo(websocket) + + +async def _ws_echo(websocket: WebSocket) -> None: + # Echo verbatim, preserving the text-vs-binary opcode. + try: + while True: + msg = await websocket.receive() + if msg["type"] == "websocket.disconnect": + return + if "text" in msg: + await websocket.send_text(msg["text"]) + elif "bytes" in msg: + await websocket.send_bytes(msg["bytes"]) + except WebSocketDisconnect: + return + + +async def _ws_hub_receiver(websocket: WebSocket) -> None: + # Pure receiver: join the hub, then drain inbound frames (the background + # loop drives all writes) until the peer goes away. Never echo. + _hub.add(websocket) + try: + while True: + msg = await websocket.receive() + if msg["type"] == "websocket.disconnect": + return + except WebSocketDisconnect: + return + finally: + _hub.discard(websocket) + + +async def _broadcast_loop() -> None: + while True: + await asyncio.sleep(_PUBLISH_INTERVAL) + # Snapshot so a disconnect mid-iteration cannot mutate the live set. + for ws in tuple(_hub): + try: + await ws.send_text(_BROADCAST_PAYLOAD) + except Exception: # drop a dead conn rather than abort the fan-out + _hub.discard(ws) + + +# --- SSE: subscriber-queue broker (the pattern the celeris sse.Broker replaces) --- + +_sse_subscribers: set[asyncio.Queue[bytes]] = set() + + +async def sse_endpoint(request: Request) -> StreamingResponse: + queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=_SSE_QUEUE_MAXSIZE) + _sse_subscribers.add(queue) + + async def event_gen() -> AsyncIterator[bytes]: + try: + while True: + if await request.is_disconnected(): + return + yield await queue.get() + finally: + _sse_subscribers.discard(queue) + + return StreamingResponse( + event_gen(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +async def _publish_loop() -> None: + while True: + await asyncio.sleep(_PUBLISH_INTERVAL) + for queue in tuple(_sse_subscribers): + try: + queue.put_nowait(_SSE_FRAME) + except asyncio.QueueFull: # slow subscriber: drop, never block fan-out + pass + + +# --- lifespan wiring ------------------------------------------------------- + +_tasks: list[asyncio.Task[None]] = [] + + +def start_streaming() -> None: + """Launch the 1ms WS-broadcast and SSE-publish tickers on the live loop.""" + _tasks.append(asyncio.create_task(_broadcast_loop())) + _tasks.append(asyncio.create_task(_publish_loop())) + + +async def stop_streaming() -> None: + """Cancel the tickers on shutdown.""" + for task in _tasks: + task.cancel() + for task in _tasks: + with contextlib.suppress(asyncio.CancelledError): + await task + _tasks.clear() + + +@contextlib.asynccontextmanager +async def lifespan(_app: Starlette) -> AsyncIterator[None]: + start_streaming() + try: + yield + finally: + await stop_streaming() diff --git a/servers/starlette/pyproject.toml b/servers/starlette/pyproject.toml index c49b7fc..ab54107 100644 --- a/servers/starlette/pyproject.toml +++ b/servers/starlette/pyproject.toml @@ -17,6 +17,9 @@ # threadpool which would distort the cell. # * `uvicorn[standard]` — pulls in uvloop, httptools, watchfiles, # websockets in one extra so we never hand-list the C accelerators. +# The WS streaming surface (app/streaming.py, /ws + /events) rides this +# `websockets` dep on the H1 path — verified `uvicorn[standard]` alone +# resolves it, so NO standalone `websockets` floor is listed here. # * `orjson` — used only by app/payload.py to generate the deterministic # 1k/8k/16k/64k bodies byte-identically to the Go reference (and to the # fastapi adapter). Not on the request hot path. From 3f9dacc7f6d2e362634a2aeec12261ea5f34797b Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Sun, 21 Jun 2026 19:58:07 +0200 Subject: [PATCH 05/11] bench(v1.5.4): equalize advertised h2 flow-control across h2c columns (W2) Audit + fix the h2c "fair fight". loadgen adopts the server's advertised SETTINGS_INITIAL_WINDOW_SIZE as its per-stream upload window, so a small advertised window throttles POST throughput independent of server speed. Measured the advertised SETTINGS of every h2c column with a wire probe: celeris ................. 1 MiB window / 1 MiB frame / 100 streams gin/echo/chi/iris/hertz/stdhttp (Go net/http) ... 1 MiB (already fair) axum-h2 / hyper-h2 (Rust hyper) ................. 1 MiB (already fair) aspnet-h2 (Kestrel) ............................. 768 KiB -> 1 MiB hono-h2 / elysia-h2 (Bun node:http2) ............ 64 KiB -> 1 MiB fastapi-h2 / starlette-h2 (hypercorn/h2) ........ 64 KiB (see caveat) So the original "h2c is mostly an artifact" premise is refuted by measurement: the entire Go field and both Rust columns already advertise celeris's 1 MiB window. Only Kestrel and the Bun columns lagged; their adapters now explicitly advertise the 1 MiB / 100-stream profile: - servers/aspnet/Program.cs: Http2 InitialStream/ConnectionWindowSize = 1 MiB, MaxStreamsPerConnection = 100. - servers/{hono,elysia}/src/h2c.ts: node:http2 settings.initialWindowSize = 1 MiB + maxConcurrentStreams 100, plus session.setLocalWindowSize to lift the connection window off its 64 KiB default. DISCLOSED CAVEAT: fastapi-h2/starlette-h2 ride hypercorn, which exposes no per-stream initial-window knob; they keep the h2-library default and can't be equalized without a fragile monkeypatch. They are the slowest columns regardless, so the window is not their binding constraint. Methodology recorded as a block comment above the h2c columns in servers/servers.go. All three patched columns re-probed from their rebuilt artifacts: each now advertises INITIAL_WINDOW=1048576. --- servers/aspnet/Program.cs | 6 ++++++ servers/elysia/src/h2c.ts | 21 ++++++++++++++++++++- servers/hono/src/h2c.ts | 21 ++++++++++++++++++++- servers/servers.go | 25 +++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/servers/aspnet/Program.cs b/servers/aspnet/Program.cs index 4208852..39a5c25 100644 --- a/servers/aspnet/Program.cs +++ b/servers/aspnet/Program.cs @@ -67,6 +67,12 @@ { options.AddServerHeader = false; options.AllowSynchronousIO = false; + // Match celeris's advertised h2 flow-control profile so the matrix is a + // fair fight: 1 MiB initial windows (Kestrel defaults below 1 MiB), + // connection window >= stream window, 100 concurrent streams. + options.Limits.Http2.InitialStreamWindowSize = 1024 * 1024; + options.Limits.Http2.InitialConnectionWindowSize = 1024 * 1024; + options.Limits.Http2.MaxStreamsPerConnection = 100; if (host is null) { options.ListenAnyIP(port, listenOptions => listenOptions.Protocols = protocols); diff --git a/servers/elysia/src/h2c.ts b/servers/elysia/src/h2c.ts index 9d0a50b..99ad461 100644 --- a/servers/elysia/src/h2c.ts +++ b/servers/elysia/src/h2c.ts @@ -51,7 +51,26 @@ export function serveH2C( port: number, handler: FetchHandler, ): Promise { - const server: Http2Server = http2.createServer(); + // Advertise the celeris H2 flow-control profile so the h2c fair-fight + // compares like for like: node:http2 otherwise defaults the per-stream + // initialWindowSize to 64 KiB, throttling large-body streams below every + // Go column and axum (all 1 MiB). maxConcurrentStreams matches celeris's + // cap of 100; maxFrameSize stays at the 16 KiB RFC minimum celeris uses. + const server: Http2Server = http2.createServer({ + settings: { + initialWindowSize: 1 << 20, + maxConcurrentStreams: 100, + maxFrameSize: 1 << 14, + }, + }); + + server.on("session", (session) => { + // The per-stream initialWindowSize above does NOT raise the connection + // (session) receive window — that stays at the ~64 KiB default and would + // cap aggregate in-flight body bytes. Lift it to 1 MiB so the session + // window never becomes the bottleneck the per-stream one no longer is. + session.setLocalWindowSize(1 << 20); + }); server.on("stream", (stream, headers) => { // node's "stream" event types the first arg as the base Http2Stream, diff --git a/servers/hono/src/h2c.ts b/servers/hono/src/h2c.ts index 9d0a50b..99ad461 100644 --- a/servers/hono/src/h2c.ts +++ b/servers/hono/src/h2c.ts @@ -51,7 +51,26 @@ export function serveH2C( port: number, handler: FetchHandler, ): Promise { - const server: Http2Server = http2.createServer(); + // Advertise the celeris H2 flow-control profile so the h2c fair-fight + // compares like for like: node:http2 otherwise defaults the per-stream + // initialWindowSize to 64 KiB, throttling large-body streams below every + // Go column and axum (all 1 MiB). maxConcurrentStreams matches celeris's + // cap of 100; maxFrameSize stays at the 16 KiB RFC minimum celeris uses. + const server: Http2Server = http2.createServer({ + settings: { + initialWindowSize: 1 << 20, + maxConcurrentStreams: 100, + maxFrameSize: 1 << 14, + }, + }); + + server.on("session", (session) => { + // The per-stream initialWindowSize above does NOT raise the connection + // (session) receive window — that stays at the ~64 KiB default and would + // cap aggregate in-flight body bytes. Lift it to 1 MiB so the session + // window never becomes the bottleneck the per-stream one no longer is. + session.setLocalWindowSize(1 << 20); + }); server.on("stream", (stream, headers) => { // node's "stream" event types the first arg as the base Http2Stream, diff --git a/servers/servers.go b/servers/servers.go index 2bb74ba..d16346e 100644 --- a/servers/servers.go +++ b/servers/servers.go @@ -219,6 +219,31 @@ var Registry = map[string]Adapter{ Capabilities: Capabilities{Static: true, WS: true, SSE: true, TLS: true}, }, + // --- HTTP/2 flow-control methodology (h2c fair-fight) --- + // + // Every -h2 column below speaks h2c prior-knowledge. loadgen adopts the + // server's advertised SETTINGS_INITIAL_WINDOW_SIZE as its per-stream + // UPLOAD window, so a server that advertises a small window throttles + // the client's POST throughput independent of how fast it actually + // processes — a flow-control artifact, not merit. To keep the matrix a + // fair fight we equalize the advertised per-stream window to celeris's + // profile (1 MiB initial window + 100 max concurrent streams; see + // celeris resource/config.go, which also notes 1 MiB matches + // golang.org/x/net/http2 and fasthttp2): + // - Go columns (gin/echo/chi/iris/hertz/stdhttp) and the Rust + // hyper-based columns (axum/hyper) already advertise a 1 MiB window + // by library default — left untouched. + // - aspnet (Kestrel) and the Bun node:http2 columns (hono/elysia) + // default below 1 MiB; their adapters now explicitly advertise the + // 1 MiB profile (servers/aspnet/Program.cs, servers/{hono,elysia}/ + // src/h2c.ts). + // - DISCLOSED CAVEAT: fastapi-h2/starlette-h2 serve h2c via hypercorn, + // which exposes no per-stream initial-window knob (it inherits the + // h2-library default); they advertise a smaller window than the rest + // and cannot be equalized without a fragile internal monkeypatch. + // They are the slowest columns regardless, so the window is not + // their binding constraint. + // // gin / echo / chi / iris — net/http-based routers. Each carries an // h1 and an h2c (h2c.NewHandler-wrapped) variant. "gin-h1": { From c9e871547eb8d7da040cf36f651b3f4719d02a45 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Sun, 21 Jun 2026 20:04:10 +0200 Subject: [PATCH 06/11] bench(v1.5.4): reconcile budget cell counts to the live grid (W5) The v1.5.4 redesign reshaped the grid (W1 pruned saturated static rows, W3 deepened drivers 4->10, W4 added WS/SSE to three columns). A live `cmd/runner -dry-run -cells '*/*'` now resolves 1111 capability-gated cells/pass (52 adapters x 44 scenarios), so the stale pins move: FastRealizedCells: 1257 -> 1111 (fast = 35s/10s window = 19.1h < 24h) FullRealizedCells: 820 -> 1111 (same realized "*/*" grid as Fast; the 820/1257 split was pre-existing drift) HeadlineWeekly's per-cell window shortened 60s/15s -> 40s/12s: the longer window no longer fits the grown grid in 24h (1111 x 92s = 28.4h), the shorter one does (1111 x 69s + ~0.7h rated = ~22.0h < 24h). All budget invariants (TestWeeklyConfigFitsBudget / TestFastFitsWithin24h) pass with the true count instead of a stale pin. Comment arithmetic updated to match. --- budget/budget_test.go | 2 +- budget/profiles.go | 45 ++++++++++++++++++++++++------------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/budget/budget_test.go b/budget/budget_test.go index 3c16e8e..eb1a049 100644 --- a/budget/budget_test.go +++ b/budget/budget_test.go @@ -139,7 +139,7 @@ func TestForProfileDefaultHasFullCoverage(t *testing.T) { t.Fatalf("default profile must cover the full grid (Globs '*/*'), got %v", def.Globs) } if def.Cells < 400 { - t.Errorf("default profile Cells: want >= 400 (the full matrix is ~800 capability-gated), got %d. "+ + t.Errorf("default profile Cells: want >= 400 (the full matrix is ~1111 capability-gated), got %d. "+ "A value this low means the default was silently scoped down to a curated subset.", def.Cells) } diff --git a/budget/profiles.go b/budget/profiles.go index 8f814bc..68a2f9b 100644 --- a/budget/profiles.go +++ b/budget/profiles.go @@ -56,17 +56,17 @@ const ( HeadlineRealizedCells = FullRealizedCells HeadlineRatedRealizedCells = 24 // 8 rated servers x 3 rated scenarios, capability-gated - // Full profile: every server x every scenario, capability-gated. After - // the mid-size payload rows (get/post-json-8k/16k) and the native h2c - // columns (axum/ntex/hyper/aspnet/fastapi/hono/elysia -h2) landed, the - // nominal grid is ~36 columns x 45 rows ~ 1620; capability gating (the - // streaming / driver / chain / TLS cells, plus the h2c-noupg columns - // skipping every H1 row) lands the realized count near ~800. Pinned - // conservatively high so FitWithin over-projects slightly and a registry - // change that blows the budget fails loudly rather than overflowing the - // run. Recompute with the scheduler's Applicable gate when the registry - // grows again. - FullRealizedCells = 820 + // Full profile: every server x every scenario, capability-gated. This is + // the SAME realized "*/*" grid Fast runs (FullRealizedCells == + // FastRealizedCells); the profiles differ only by per-cell window. The + // v1.5.4 redesign reshaped the grid — saturated static rows pruned (W1), + // the driver set deepened 4->10 (W3), and WS/SSE coverage added to three + // more columns (W4) — so the realized count moved off the older ~800/1257 + // pins. Recompute with `cmd/runner -dry-run -cells '*/*' | grep -c + // '^run0'` when the registry changes; the nominal grid is 52 columns x 44 + // rows and capability gating (the streaming / driver / chain / TLS cells, + // plus the h2c-noupg columns skipping every H1 row) lands it here. + FullRealizedCells = 1111 FullRatedRealizedCells = 24 ) @@ -88,16 +88,19 @@ const ( // the correct loud failure, since the full grid x 2 serial arches cannot fit // 24h until ArchParallel (#168, blocked on loadgen linux/arm64) lands. // -// Budget: ~820 cells x (15+60+5+12)s x 1 arch = ~20.9h saturation + ~0.7h -// curated rated = ~21.6h < 24h. The rated sweep stays curated (RatedGlobs) -// because it is the expensive additive dimension; expanding it to the full -// grid would blow the budget many times over. +// Budget: ~1111 cells x (12+40+5+12)s x 1 arch = ~21.3h saturation + ~0.7h +// curated rated = ~22.0h < 24h. The per-cell window was shortened from +// 60s/15s to 40s/12s in the v1.5.4 redesign so the grown full grid (1111 +// realized cells, up from ~820) still fits 24h — the longer window no +// longer does. The rated sweep stays curated (RatedGlobs) because it is the +// expensive additive dimension; expanding it to the full grid would blow +// the budget many times over. func HeadlineWeekly() Profile { return Profile{ Name: "headline", Cells: HeadlineRealizedCells, - Duration: 60 * time.Second, - Warmup: 15 * time.Second, + Duration: 40 * time.Second, + Warmup: 12 * time.Second, Cooldown: defaultCooldown, Runs: 1, Arches: 1, @@ -116,7 +119,9 @@ func HeadlineWeekly() Profile { // Recompute with `cmd/runner -dry-run -cells '*/*' | grep -c '^run0'` when // the registry grows; FitWithin uses it to assert the fast profile still // fits 24h, so an over-large grid fails loudly instead of overrunning. -const FastRealizedCells = 1257 +// v1.5.4 redesign: 1257 -> 1111 (W1 pruned saturated static rows; W3 +// deepened drivers 4->10; W4 added WS/SSE to three columns). +const FastRealizedCells = 1111 // Fast is the DEFAULT routine + weekly profile: the FULL grid (every server // × every scenario, capability-gated, "*/*") in SATURATION ONLY — no rated @@ -126,8 +131,8 @@ const FastRealizedCells = 1257 // per cell, the dominant cost) is intentionally OFF here and belongs in a // separate, scoped dispatch when latency-under-controlled-load is the story. // -// Budget: 1257 cells × (10+35+5+12)s × 1 arch = ~21.6h saturation, rated=0 -// → ~21.6h < 24h. RatedPasses=0 makes BenchTier skip the rated flag entirely +// Budget: 1111 cells × (10+35+5+12)s × 1 arch = ~19.1h saturation, rated=0 +// → ~19.1h < 24h. RatedPasses=0 makes BenchTier skip the rated flag entirely // (rated OFF for every cell), so this is the cheap, full-breadth mode. func Fast() Profile { return Profile{ From 78679b8371e10b52e9bb321b531f037b9bf161c8 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Mon, 22 Jun 2026 21:45:28 +0200 Subject: [PATCH 07/11] =?UTF-8?q?bench(v1.5.4):=20pre-run=20audit=20fixes?= =?UTF-8?q?=20=E2=80=94=20remove=20unfair=20chain=20scenarios,=20unify=20s?= =?UTF-8?q?ession-rw,=20pg=20synccommit,=20post-1m=20non-ranking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pre-run suite audit found 3 run-invalidating issues. Fixed all: 1. MIDDLEWARE/CHAIN REMOVED (12 scenarios): the 4 chain stacks compared unequal middleware work across adapters in OPPOSITE directions (celeris 0 CORS headers + 0-work CSRF but 9 secure headers + full structured logger; competitors 3 CORS + 1 CSRF cookie + 4 secure + 2-token logger; echo/iris a third set). Any fullstack/security ranking off that was an artifact, not a finding. Owner decision: remove them (also saves ~276 cells of compute). scenarios/chain.go init() now registers nothing. 2. driver-session-rw UNIFIED across all 9 adapters: it ran 0-3 backend round-trips per adapter (iris used an in-memory store touching NO redis; gin INCR+EXPIRE=2; echo HSET+HINCRBY+EXPIRE=3; others 1 SET) — not a framework comparison. Now every adapter does an identical GET+SET of the fixed key `pmsess:bench` (seeded in services.go) + a uniform 256B body merge = exactly 2 redis ops. celeris keeps its NATIVE driver, competitors go-redis (the intended native-vs-idiomatic split; only the OPS match). (Also fixed servers/stdhttp which was non-compiling on the branch.) 3. driver-pg-update-tx: postgres now runs `-c synchronous_commit=off` (services.go local + ansible bench.yml/validate.yml cluster) so the BEGIN;UPDATE;COMMIT measures driver/framework overhead, not host disk fsync rate (was ~1k RPS = disk, identical for everyone). 4. post-1m is now UNCONDITIONALLY excluded from the RPS ranking (report/document.go isWireBoundByDesign) — its runtime network-bound guard no-ops on the Tailscale overlay (line rate unknown), so it would otherwise publish as a raw-RPS row. Grid: 41->29 scenarios, 1111->835 cells/pass (~14.4h fast, single arch). Budget constants reconciled. Root + all 9 adapter modules build; budget + scenarios + report tests pass. --- ansible/bench.yml | 2 +- ansible/validate.yml | 2 +- budget/profiles.go | 38 ++++++------ report/document.go | 20 +++++- scenarios/chain.go | 13 ++-- scenarios/driver.go | 6 +- scenarios/scenarios_test.go | 2 +- servers/celeris/driver_handlers.go | 95 ++++++++++++++--------------- servers/chi/driver_handlers.go | 64 ++++++------------- servers/echo/driver_handlers.go | 73 ++++++++++------------ servers/fasthttp/driver_handlers.go | 54 +++++----------- servers/fiber/driver_handlers.go | 32 +++++----- servers/gin/driver_handlers.go | 66 ++++++++++++-------- servers/hertz/driver_handlers.go | 36 +++++------ servers/iris/driver_handlers.go | 71 ++++++++++++--------- servers/stdhttp/driver_handlers.go | 64 ++++++------------- services/services.go | 23 +++++++ 17 files changed, 315 insertions(+), 346 deletions(-) diff --git a/ansible/bench.yml b/ansible/bench.yml index edd7880..f2725ae 100644 --- a/ansible/bench.yml +++ b/ansible/bench.yml @@ -134,7 +134,7 @@ docker rm -f probatorium-pg probatorium-redis probatorium-mc 2>/dev/null docker run -d --rm --name probatorium-pg -p 127.0.0.1:54321:5432 \ -e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench \ - postgres:17-alpine + postgres:17-alpine -c synchronous_commit=off docker run -d --rm --name probatorium-redis -p 127.0.0.1:63791:6379 redis:8.2-alpine docker run -d --rm --name probatorium-mc -p 127.0.0.1:21211:11211 memcached:1.6.41-alpine args: diff --git a/ansible/validate.yml b/ansible/validate.yml index 4915b9e..665c777 100644 --- a/ansible/validate.yml +++ b/ansible/validate.yml @@ -90,7 +90,7 @@ docker run -d --rm --name probatorium-pg \ -p 127.0.0.1:54321:5432 \ -e POSTGRES_USER=bench -e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench \ - postgres:17-alpine + postgres:17-alpine -c synchronous_commit=off register: dbsvc_pg changed_when: dbsvc_pg.rc == 0 failed_when: false diff --git a/budget/profiles.go b/budget/profiles.go index 68a2f9b..0b7eb51 100644 --- a/budget/profiles.go +++ b/budget/profiles.go @@ -60,13 +60,13 @@ const ( // the SAME realized "*/*" grid Fast runs (FullRealizedCells == // FastRealizedCells); the profiles differ only by per-cell window. The // v1.5.4 redesign reshaped the grid — saturated static rows pruned (W1), - // the driver set deepened 4->10 (W3), and WS/SSE coverage added to three - // more columns (W4) — so the realized count moved off the older ~800/1257 - // pins. Recompute with `cmd/runner -dry-run -cells '*/*' | grep -c - // '^run0'` when the registry changes; the nominal grid is 52 columns x 44 - // rows and capability gating (the streaming / driver / chain / TLS cells, - // plus the h2c-noupg columns skipping every H1 row) lands it here. - FullRealizedCells = 1111 + // the driver set deepened 4->10 (W3), WS/SSE coverage added to three more + // columns (W4), and the 12 middleware/chain scenarios REMOVED (pre-run + // audit: they compared unequal work across adapters) — so the realized + // count moved off the older ~800/1257/1111 pins to 835. Recompute with + // `cmd/runner -dry-run -cells '*/*' | grep -c '^run0'` when the registry + // changes; the grid is now 52 columns x 29 rows, capability-gated. + FullRealizedCells = 835 FullRatedRealizedCells = 24 ) @@ -88,13 +88,12 @@ const ( // the correct loud failure, since the full grid x 2 serial arches cannot fit // 24h until ArchParallel (#168, blocked on loadgen linux/arm64) lands. // -// Budget: ~1111 cells x (12+40+5+12)s x 1 arch = ~21.3h saturation + ~0.7h -// curated rated = ~22.0h < 24h. The per-cell window was shortened from -// 60s/15s to 40s/12s in the v1.5.4 redesign so the grown full grid (1111 -// realized cells, up from ~820) still fits 24h — the longer window no -// longer does. The rated sweep stays curated (RatedGlobs) because it is the -// expensive additive dimension; expanding it to the full grid would blow -// the budget many times over. +// Budget: ~835 cells x (12+40+5+12)s x 1 arch = ~16.0h saturation + ~0.7h +// curated rated = ~16.7h < 24h. The per-cell window stays at the v1.5.4 +// 40s/12s (the chain-scenario removal dropped the grid 1111->835, so there +// is now ample headroom). The rated sweep stays curated (RatedGlobs) because +// it is the expensive additive dimension; expanding it to the full grid +// would blow the budget many times over. func HeadlineWeekly() Profile { return Profile{ Name: "headline", @@ -119,9 +118,10 @@ func HeadlineWeekly() Profile { // Recompute with `cmd/runner -dry-run -cells '*/*' | grep -c '^run0'` when // the registry grows; FitWithin uses it to assert the fast profile still // fits 24h, so an over-large grid fails loudly instead of overrunning. -// v1.5.4 redesign: 1257 -> 1111 (W1 pruned saturated static rows; W3 -// deepened drivers 4->10; W4 added WS/SSE to three columns). -const FastRealizedCells = 1111 +// v1.5.4 redesign: 1257 -> 1111 -> 835 (W1 pruned saturated static rows; W3 +// deepened drivers 4->10; W4 added WS/SSE to three columns; pre-run audit +// REMOVED the 12 middleware/chain scenarios as unfair). +const FastRealizedCells = 835 // Fast is the DEFAULT routine + weekly profile: the FULL grid (every server // × every scenario, capability-gated, "*/*") in SATURATION ONLY — no rated @@ -131,8 +131,8 @@ const FastRealizedCells = 1111 // per cell, the dominant cost) is intentionally OFF here and belongs in a // separate, scoped dispatch when latency-under-controlled-load is the story. // -// Budget: 1111 cells × (10+35+5+12)s × 1 arch = ~19.1h saturation, rated=0 -// → ~19.1h < 24h. RatedPasses=0 makes BenchTier skip the rated flag entirely +// Budget: 835 cells × (10+35+5+12)s × 1 arch = ~14.4h saturation, rated=0 +// → ~14.4h < 24h. RatedPasses=0 makes BenchTier skip the rated flag entirely // (rated OFF for every cell), so this is the cheap, full-breadth mode. func Fast() Profile { return Profile{ diff --git a/report/document.go b/report/document.go index 7616f2c..24ab0d2 100644 --- a/report/document.go +++ b/report/document.go @@ -192,9 +192,13 @@ func BuildDocument(in BuildInput) *Document { // loadgen still had CPU headroom is NIC-limited, not server-limited. // Its saturation RPS converges across every fast adapter and must not // be read as a ranking — the CPU efficiency in Resources is the real - // signal. Only flagged when the fabric's line rate is known (the LAN; - // the Tailscale overlay reports 0 and flags nothing). - if isNetworkBound(c.BytesMedian, c.LoadgenCPUP95, in.Environment.FabricLineRateBitsPerSec) { + // signal. Runtime detection only fires when the fabric's line rate is + // known (the LAN; the Tailscale overlay reports 0 and flags nothing), + // so the wire-bound-by-design scenarios are OR'd in unconditionally: + // post-1m is a documented wire-bound datapoint, never a ranking row, + // regardless of whether the line rate was measurable. + if isWireBoundByDesign(c.ScenarioName) || + isNetworkBound(c.BytesMedian, c.LoadgenCPUP95, in.Environment.FabricLineRateBitsPerSec) { if sr.NetworkBound == nil { sr.NetworkBound = map[string]bool{} } @@ -240,6 +244,16 @@ const ( networkBoundLoadgenCPUCeiling = 8.0 ) +// isWireBoundByDesign reports whether a scenario is wire-bound by design +// rather than by runtime measurement. post-1m is a documented 1 MiB-payload +// datapoint whose saturation RPS is dictated by the fabric, not the server, +// so it must always land in the wire-bound section and never head a raw-RPS +// ranking — even on overlays where the line rate is unknown and isNetworkBound +// cannot fire. +func isWireBoundByDesign(scenarioName string) bool { + return scenarioName == "post-1m" +} + // isNetworkBound reports whether a cell's achieved egress bandwidth sat at // the fabric line rate (NIC-limited) rather than the server's CPU limit. // bytesPerSec is the median across-runs throughput; loadgenCPUP95 is the diff --git a/scenarios/chain.go b/scenarios/chain.go index df71cf3..1d3bace 100644 --- a/scenarios/chain.go +++ b/scenarios/chain.go @@ -135,10 +135,9 @@ func chainScenarioName(chain, workload string) string { return "chain-" + chain + "-" + workload } -func init() { - for _, chain := range ChainProfiles { - for _, wl := range []string{ChainWorkloadGetJSON, ChainWorkloadPost4K, ChainWorkloadGetJSON1C} { - Register(NewChainScenario(chainScenarioName(chain, wl), chain, wl)) - } - } -} +// Chain scenarios are intentionally NOT registered: they compare unequal +// work across adapters (each framework's middleware stack differs), so the +// numbers aren't comparable and aren't worth the compute. The types above +// are retained as harmless dead code so the framework-side chain_handlers.go +// and the ChainProfiles contract stay buildable. +func init() {} diff --git a/scenarios/driver.go b/scenarios/driver.go index d7f3485..64700b6 100644 --- a/scenarios/driver.go +++ b/scenarios/driver.go @@ -16,7 +16,7 @@ const ( DriverPG = "driver-pg-read" // GET /db/user/42 — 1 SELECT (hot row) DriverRedis = "driver-redis-get" // GET /cache/ — 1 GET DriverMemcached = "driver-mc-get" // GET /mc/ — 1 GET - DriverSession = "driver-session-rw" // POST /session — GET + SET round-trip + DriverSession = "driver-session-rw" // POST /session — fixed-key GET+SET round-trip (no cookie) // v1.5.4 depth additions: DriverPGWrite = "driver-pg-write" // POST /db/insert — 1 INSERT (write path) @@ -85,7 +85,9 @@ func (s *DriverScenario) Kind() string { return s.kind } // Workload returns the loadgen.Config for this driver scenario. // driver-pg-read pins id=42 (seeded by services.Seed); driver-redis-get // and driver-mc-get both request services.FixtureDemoKey; driver-session-rw -// POSTs a 256-byte payload to /session. +// POSTs a 256-byte payload to /session, which the handler turns into a +// GET+SET round-trip on the fixed server-side key services.FixtureSessionKey +// (no cookie — the key is constant). func (s *DriverScenario) Workload(target string) loadgen.Config { cfg := loadgen.Config{ Connections: 128, diff --git a/scenarios/scenarios_test.go b/scenarios/scenarios_test.go index 5657ba2..e8d7bd2 100644 --- a/scenarios/scenarios_test.go +++ b/scenarios/scenarios_test.go @@ -172,7 +172,7 @@ func TestErrorBudgets(t *testing.T) { if got := ErrorBudgetFor(findScenario(t, "churn-close")); got != 0.5 { t.Errorf("churn-close ErrorBudget = %v, want 0.5", got) } - for _, name := range []string{"get-json", "post-4k", "sse-fanout-1024", "chain-api-post-4k"} { + for _, name := range []string{"get-json", "post-4k", "sse-fanout-1024"} { if got := ErrorBudgetFor(findScenario(t, name)); got != DefaultErrorBudget { t.Errorf("%s ErrorBudget = %v, want DefaultErrorBudget (%v)", name, got, DefaultErrorBudget) } diff --git a/servers/celeris/driver_handlers.go b/servers/celeris/driver_handlers.go index c3afc06..ea3c05a 100644 --- a/servers/celeris/driver_handlers.go +++ b/servers/celeris/driver_handlers.go @@ -7,9 +7,9 @@ // - GET /db/user/:id — driver/postgres pool, SELECT by id, JSON row. // - GET /cache/:key — driver/redis, GET raw bytes. // - GET /mc/:key — driver/memcached, GET raw bytes. -// - POST /session — middleware/session over a redisstore backend: -// load/merge/save a hit counter keyed by the pmsid cookie, JSON -// {ok,seq} reply. +// - POST /session — driver/redis GET+SET round-trip on the fixed key +// pmsess:bench: load the seeded blob, merge the JSON body, save with a +// 10-minute TTL, JSON {ok,seq} reply. Exactly two round-trips. // // Each celeris driver is opened WithEngine(srv) so that — when the server // runs with AsyncHandlers — the driver auto-selects its direct net.Conn @@ -42,12 +42,13 @@ import ( "github.com/goceleris/celeris/driver/memcached" "github.com/goceleris/celeris/driver/postgres" "github.com/goceleris/celeris/driver/redis" - "github.com/goceleris/celeris/middleware/session" - "github.com/goceleris/celeris/middleware/session/redisstore" - - "github.com/goceleris/probatorium/servers/common" ) +// sessionKey is the fixed key every adapter's POST /session round-trips +// against, so the workload is a load+merge+save of one seeded blob — no +// per-cookie key fan-out — and identical across frameworks. +const sessionKey = "pmsess:bench" + // userRow mirrors the seeded users table row (id, name, email, score), // matching the reference's userRow so the JSON body is identical across // adapters. @@ -59,8 +60,8 @@ type userRow struct { } // sessionResponse is the JSON body returned by POST /session. seq is the -// session's hit counter, incremented on every request carrying the same -// pmsid cookie. Shape matches the reference. +// hit counter loaded from the fixed-key blob and bumped on every request. +// Shape matches the reference. type sessionResponse struct { OK bool `json:"ok"` Seq int `json:"seq"` @@ -93,13 +94,11 @@ func envOr(key, def string) string { return def } -// driverClients are the lazily-opened, process-lifetime backend handles -// plus the per-route session middleware. +// driverClients are the lazily-opened, process-lifetime backend handles. type driverClients struct { - pg *postgres.Pool - redis *redis.Client - mc *memcached.Client - sessionMW celeris.HandlerFunc + pg *postgres.Pool + redis *redis.Client + mc *memcached.Client } // mountDriverHandlers opens the celeris drivers and attaches the four @@ -141,17 +140,6 @@ func mountDriverHandlers(srv *celeris.Server) *driverClients { } } - // Session middleware over a redisstore backend, available only when - // Redis opened. The cookie name and idle timeout match the reference. - if c.redis != nil { - store := redisstore.New(c.redis) - c.sessionMW = session.New(session.Config{ - Store: store, - CookieName: common.SessionCookieName, - IdleTimeout: 10 * time.Minute, - }) - } - srv.GET("/db/user/:id", c.dbUserHandler).Async() srv.GET("/cache/:key", c.cacheHandler).Async() srv.GET("/mc/:key", c.mcHandler).Async() @@ -165,16 +153,7 @@ func mountDriverHandlers(srv *celeris.Server) *driverClients { srv.GET("/cache-pipeline", c.cachePipelineHandler).Async() srv.GET("/mc-multiget", c.mcMultiGetHandler).Async() - // The session middleware is mounted as a per-route layer (not globally) - // so its load/save round-trip only fires on /session requests. When - // Redis is unavailable the route degrades to a deterministic 503. - if c.sessionMW != nil { - srv.POST("/session", sessionTerminal).Use(c.sessionMW).Async() - } else { - srv.POST("/session", func(ctx *celeris.Context) error { - return ctx.AbortWithStatus(503) - }).Async() - } + srv.POST("/session", c.sessionHandler).Async() return c } @@ -376,27 +355,47 @@ func (c *driverClients) mcMultiGetHandler(ctx *celeris.Context) error { return ctx.JSON(200, sessionResponse{OK: true, Seq: len(vals)}) } -// sessionTerminal is the inner handler the session middleware wraps (via -// Route.Use): the middleware loads the session, calls c.Next() into this -// terminal, then saves on the way out. A loadgen client that reuses the -// pmsid cookie observes a monotonically increasing seq, proving the store -// round-trip. This terminal merges the request body if it is JSON, bumps -// seq, and replies {ok,seq}. -func sessionTerminal(ctx *celeris.Context) error { - sess := session.FromContext(ctx) - if sess == nil { +// sessionHandler serves POST /session via the native redis driver: GET the +// fixed-key blob (ErrNil when unseeded is ignored), merge the JSON request +// body, bump the seq hit counter, then SET the blob back with a 10-minute +// TTL. Exactly two round-trips (GET then SET) — identical work to every +// other adapter, differing only in this being celeris's in-tree driver. No +// Redis -> 503. +func (c *driverClients) sessionHandler(ctx *celeris.Context) error { + if c.redis == nil { return ctx.AbortWithStatus(503) } + qctx, cancel := context.WithTimeout(ctx.Context(), 5*time.Second) + defer cancel() + + blob := map[string]any{} + if raw, err := c.redis.GetBytes(qctx, sessionKey); err == nil { + _ = json.Unmarshal(raw, &blob) + } + + // Merge the JSON request body if present (the scenario POSTs a ~256B + // payload). Parse failures are non-fatal. if body := ctx.Body(); len(body) > 0 { var payload map[string]any if err := json.Unmarshal(body, &payload); err == nil { for k, v := range payload { - sess.Set(k, v) + blob[k] = v } } } - seq := sess.GetInt("seq") + 1 - sess.Set("seq", seq) + + seq := 0 + if n, ok := blob["seq"].(float64); ok { // JSON numbers decode to float64 + seq = int(n) + } + seq++ + blob["seq"] = seq + + if raw, err := json.Marshal(blob); err == nil { + if err := c.redis.SetBytes(qctx, sessionKey, raw, 10*time.Minute); err != nil { + return ctx.AbortWithStatus(503) + } + } return ctx.JSON(200, sessionResponse{OK: true, Seq: seq}) } diff --git a/servers/chi/driver_handlers.go b/servers/chi/driver_handlers.go index 4b4a66f..e038e41 100644 --- a/servers/chi/driver_handlers.go +++ b/servers/chi/driver_handlers.go @@ -6,8 +6,8 @@ // - GET /db/user/{id} — jackc/pgx/v5 pool, SELECT by id, JSON row. // - GET /cache/{key} — redis/go-redis/v9, GET raw bytes. // - GET /mc/{key} — bradfitz/gomemcache, GET raw bytes. -// - POST /session — go-redis-backed session: load/merge/save a hit -// counter keyed by the pmsid cookie, JSON {ok,seq} reply. +// - POST /session — go-redis GET+SET round-trip on the fixed key +// pmsess:bench: load/merge/save a hit counter, JSON {ok,seq} reply. // // Backends are addressed via environment variables (see driverConfig). // The probatorium binary takes only -bind/-engine flags, so the @@ -24,8 +24,6 @@ package main import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "errors" "io" @@ -40,6 +38,11 @@ import ( "github.com/redis/go-redis/v9" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // userRow mirrors the seeded users table row (id, name, email, score), // matching the celeris reference's userRow so the JSON body is identical // across adapters. @@ -51,8 +54,8 @@ type userRow struct { } // sessionResponse is the JSON body returned by POST /session. seq is the -// session's hit counter, incremented on every request carrying the same -// pmsid cookie. Shape matches the celeris reference. +// hit counter loaded from the fixed-key blob and bumped on every request. +// Shape matches the celeris reference. type sessionResponse struct { OK bool `json:"ok"` Seq int `json:"seq"` @@ -362,15 +365,11 @@ func (c *driverClients) mcHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(item.Value) } -// sessionHandler serves POST /session with a Redis-backed session store, -// mirroring the celeris reference's redisstore session: load the blob by -// the pmsid cookie, merge any JSON request body, bump the seq hit -// counter, persist, and reply {ok,seq}. No Redis -> 503. -// -// The store is a plain Redis hash-free JSON blob under "sess:" with -// a 10-minute TTL (the reference's IdleTimeout). A loadgen client that -// reuses the cookie observes a monotonically increasing seq, proving the -// store round-trip; a fresh client starts at 1. +// sessionHandler serves POST /session over a Redis-backed JSON blob on the +// fixed key pmsess:bench: GET the blob (redis.Nil on the unseeded key is +// ignored), merge any JSON request body, bump the seq hit counter, then SET +// the blob back with a 10-minute TTL. Exactly two round-trips (GET then SET) +// — the same workload every adapter runs, over go-redis here. No Redis -> 503. func (c *driverClients) sessionHandler(w http.ResponseWriter, r *http.Request) { if c.redis == nil { w.WriteHeader(http.StatusServiceUnavailable) @@ -379,14 +378,9 @@ func (c *driverClients) sessionHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - sid, fresh := sessionID(r) - key := "sess:" + sid - blob := map[string]any{} - if !fresh { - if raw, err := c.redis.Get(ctx, key).Bytes(); err == nil { - _ = json.Unmarshal(raw, &blob) - } + if raw, err := c.redis.Get(ctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &blob) } // Merge a JSON request body if present (the scenario POSTs a ~256B @@ -408,35 +402,17 @@ func (c *driverClients) sessionHandler(w http.ResponseWriter, r *http.Request) { blob["seq"] = seq if raw, err := json.Marshal(blob); err == nil { - _ = c.redis.Set(ctx, key, raw, 10*time.Minute).Err() + if err := c.redis.Set(ctx, sessionKey, raw, 10*time.Minute).Err(); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } } - if fresh { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: sid, - Path: "/", - HttpOnly: true, - }) - } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(sessionResponse{OK: true, Seq: seq}) } -// sessionID returns the session id from the pmsid cookie, or a freshly -// generated one (with fresh=true so the caller emits a Set-Cookie). -func sessionID(r *http.Request) (id string, fresh bool) { - if ck, err := r.Cookie(sessionCookieName); err == nil && ck.Value != "" { - return ck.Value, false - } - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return strconv.FormatInt(time.Now().UnixNano(), 16), true - } - return hex.EncodeToString(b[:]), true -} - // Compile-time guard so the errors import stays tied to real behavior even // if a future edit drops its only use. var _ = errors.New diff --git a/servers/echo/driver_handlers.go b/servers/echo/driver_handlers.go index e0e1c2b..16cefb6 100644 --- a/servers/echo/driver_handlers.go +++ b/servers/echo/driver_handlers.go @@ -11,15 +11,17 @@ import ( "time" "github.com/bradfitz/gomemcache/memcache" - "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" echov4 "github.com/labstack/echo/v4" "github.com/redis/go-redis/v9" - - "github.com/goceleris/probatorium/servers/common" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // driverPoolSize bounds every backing client so the bench is gated by the // server under test rather than by an unbounded client-side connection // fan-out. Mirrors the pool size the celeris in-tree drivers use (16). @@ -40,8 +42,7 @@ type userRow struct { } // sessionResponse is the JSON body returned by POST /session; Seq is the -// session's hit counter, incremented on every request bound to the same -// cookie. +// hit counter loaded from the fixed-key blob and bumped on every request. type sessionResponse struct { OK bool `json:"ok"` Seq int `json:"seq"` @@ -173,61 +174,51 @@ func registerDriverHandlers(e *echov4.Echo, dc *driverClients) { return c.Blob(http.StatusOK, "application/octet-stream", item.Value) }) - // POST /session — cookie-keyed session round-trip backed by Redis. + // POST /session — fixed-key session round-trip backed by Redis. // - // Echo ships no session middleware in the core module and there is no - // celeris session store to reuse, so the round-trip is expressed - // directly: read (or mint) the pmsid cookie, merge the request body - // into the session blob, increment a per-session hit counter, and - // reply with the current seq. This is the semantic equal of the - // celeris adapter's session.New(...) + sessionHandler — a load + merge - // + save + counter round-trip per request on the same cookie — using - // the same Redis backend. + // Echo ships no session middleware in the core module, so the + // round-trip is expressed directly: GET the fixed-key blob (redis.Nil + // on the unseeded key is ignored), merge the JSON request body, bump + // the seq hit counter, then SET the blob back with a 10-minute TTL. + // Exactly two round-trips (GET then SET) — the same workload every + // adapter runs, over go-redis here. e.POST("/session", func(c echov4.Context) error { if dc.redis == nil { return c.NoContent(http.StatusServiceUnavailable) } - sid := "" - if ck, err := c.Cookie(common.SessionCookieName); err == nil && ck.Value != "" { - sid = ck.Value - } - if sid == "" { - sid = uuid.NewString() - c.SetCookie(&http.Cookie{ - Name: common.SessionCookieName, - Value: sid, - Path: "/", - HttpOnly: true, - }) - } - ctx, cancel := context.WithTimeout(c.Request().Context(), driverOpTimeout) defer cancel() - key := "pmsess:" + sid + blob := map[string]any{} + if raw, err := dc.redis.Get(ctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &blob) + } - // Merge the request body into the session blob when it parses as - // JSON. Parse failures are non-fatal: the counter round-trip below - // still runs so the session path is observable on every request. + // Merge the request body when it parses as JSON. Parse failures are + // non-fatal: the SET below still runs so the path is observable. if body, err := io.ReadAll(c.Request().Body); err == nil && len(body) > 0 { var payload map[string]any - if json.Unmarshal(body, &payload) == nil && len(payload) > 0 { - fields := make(map[string]any, len(payload)) + if json.Unmarshal(body, &payload) == nil { for k, v := range payload { - fields["d:"+k] = v + blob[k] = v } - _ = dc.redis.HSet(ctx, key, fields).Err() } } - seq, err := dc.redis.HIncrBy(ctx, key, "seq", 1).Result() - if err != nil { - return c.NoContent(http.StatusServiceUnavailable) + seq := 0 + if n, ok := blob["seq"].(float64); ok { // JSON numbers decode to float64 + seq = int(n) } - _ = dc.redis.Expire(ctx, key, 10*time.Minute).Err() + seq++ + blob["seq"] = seq - return c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: int(seq)}) + if raw, err := json.Marshal(blob); err == nil { + if err := dc.redis.Set(ctx, sessionKey, raw, 10*time.Minute).Err(); err != nil { + return c.NoContent(http.StatusServiceUnavailable) + } + } + return c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: seq}) }) // v1.5.4 driver-depth routes (idiomatic pgx/go-redis/gomemcache). The diff --git a/servers/fasthttp/driver_handlers.go b/servers/fasthttp/driver_handlers.go index 75a6ecf..90fbfb5 100644 --- a/servers/fasthttp/driver_handlers.go +++ b/servers/fasthttp/driver_handlers.go @@ -15,8 +15,6 @@ package main import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "net/http" "os" @@ -32,6 +30,11 @@ import ( "github.com/goceleris/probatorium/servers/common" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // Backend address environment variables the runner sets before launching // the adapter. An empty value means the backend is absent and the route // responds 503. Names match the validation refapps and ansible/validate.yml. @@ -65,8 +68,7 @@ type userRow struct { } // sessionResponse is the JSON body returned by POST /session; seq is the -// session's hit counter, incremented on every request bound to the same -// cookie. +// hit counter loaded from the fixed-key blob and bumped on every request. type sessionResponse struct { OK bool `json:"ok"` Seq int `json:"seq"` @@ -418,11 +420,11 @@ func mcHandler(prefix string) fasthttp.RequestHandler { } } -// sessionHandler serves POST /session — a redis-backed session round-trip -// that merges the request body into the stored blob and bumps a hit -// counter. Matches the celeris perfmatrix reference's wire behaviour -// (common.SessionCookieName cookie, "pmsess:" key prefix, 10-minute TTL) -// so the session scenario is comparable across frameworks. +// sessionHandler serves POST /session — a fixed-key redis round-trip over +// go-redis: GET the seeded blob on pmsess:bench (redis.Nil on the unseeded +// key is ignored), merge the JSON request body, bump the seq hit counter, +// then SET the blob back with a 10-minute TTL. Exactly two round-trips (GET +// then SET) — the same workload every adapter runs. No Redis -> 503. func sessionHandler() fasthttp.RequestHandler { return func(rc *fasthttp.RequestCtx) { rdb := mountedDrivers.rdb @@ -433,22 +435,9 @@ func sessionHandler() fasthttp.RequestHandler { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - sid := string(rc.Request.Header.Cookie(common.SessionCookieName)) data := make(map[string]any, 4) - if sid != "" { - raw, err := rdb.Get(ctx, "pmsess:"+sid).Bytes() - switch { - case err == nil: - _ = json.Unmarshal(raw, &data) - case err == goredis.Nil: - // New session under an unknown cookie: start fresh. - default: - rc.SetStatusCode(fasthttp.StatusServiceUnavailable) - return - } - } - if sid == "" { - sid = newSessionID() + if raw, err := rdb.Get(ctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &data) } if body := rc.PostBody(); len(body) > 0 { var incoming map[string]any @@ -463,19 +452,11 @@ func sessionHandler() fasthttp.RequestHandler { data["seq"] = newSeq buf, _ := json.Marshal(data) - if err := rdb.Set(ctx, "pmsess:"+sid, buf, 10*time.Minute).Err(); err != nil { + if err := rdb.Set(ctx, sessionKey, buf, 10*time.Minute).Err(); err != nil { rc.SetStatusCode(fasthttp.StatusServiceUnavailable) return } - ck := fasthttp.AcquireCookie() - ck.SetKey(common.SessionCookieName) - ck.SetValue(sid) - ck.SetPath("/") - ck.SetHTTPOnly(true) - rc.Response.Header.SetCookie(ck) - fasthttp.ReleaseCookie(ck) - rc.SetContentType("application/json") rc.SetStatusCode(fasthttp.StatusOK) _, _ = rc.Write(mustJSON(sessionResponse{OK: true, Seq: newSeq})) @@ -496,13 +477,6 @@ func closeDrivers() { mountedDrivers.mc = nil } -// newSessionID returns a random 128-bit hex session id. -func newSessionID() string { - var b [16]byte - _, _ = rand.Read(b[:]) - return hex.EncodeToString(b[:]) -} - // mustJSON marshals v or panics; the driver response types are fixed // shapes that cannot fail to marshal. func mustJSON(v any) []byte { diff --git a/servers/fiber/driver_handlers.go b/servers/fiber/driver_handlers.go index 898f07b..4a89150 100644 --- a/servers/fiber/driver_handlers.go +++ b/servers/fiber/driver_handlers.go @@ -9,11 +9,15 @@ import ( "github.com/bradfitz/gomemcache/memcache" "github.com/gofiber/fiber/v2" - "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" goredis "github.com/redis/go-redis/v9" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // driverState holds the lazily-opened backend clients. Each field is nil // when the matching PROBATORIUM_* env var is unset; the handler then // answers 503 so loadgen counts errors deterministically rather than the @@ -68,7 +72,7 @@ func buildDriverState() *driverState { // mountDriverHandlers registers the 4 driver routes on app as native // fiber v2 handlers. The workloads mirror the celeris reference: a PG // row read, Redis/memcached GETs returning the raw blob, and a session -// read-merge-write round-trip keyed on the pmsid cookie. +// load+merge+save round-trip on the fixed key pmsess:bench. func mountDriverHandlers(app *fiber.App, ds *driverState) { app.Get("/db/user/:id", func(c *fiber.Ctx) error { if ds.pg == nil { @@ -255,27 +259,20 @@ func mountDriverHandlers(app *fiber.App, ds *driverState) { }) app.Post("/session", func(c *fiber.Ctx) error { - // The session round-trip is Redis-backed (parity with celeris's - // redisstore). Without Redis there is nowhere to persist the blob, - // so the route is 503 — matching the driver-unavailable contract. + // Fixed-key round-trip over go-redis: GET the seeded blob (redis.Nil + // on the unseeded key is ignored), merge the JSON request body, bump + // the seq hit counter, then SET the blob back with a 10-minute TTL. + // Exactly two round-trips (GET then SET) — the same workload every + // adapter runs. No Redis -> 503. if ds.rdb == nil { return c.SendStatus(fiber.StatusServiceUnavailable) } ctx, cancel := context.WithTimeout(c.UserContext(), 5*time.Second) defer cancel() - sid := c.Cookies("pmsid") data := make(map[string]any, 4) - if sid != "" { - raw, err := ds.rdb.Get(ctx, "pmsess:"+sid).Bytes() - if err == nil { - _ = json.Unmarshal(raw, &data) - } else if err != goredis.Nil { - return c.SendStatus(fiber.StatusServiceUnavailable) - } - } - if sid == "" { - sid = uuid.NewString() + if raw, err := ds.rdb.Get(ctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &data) } // Merge the request body if it is JSON. Parse failures are @@ -298,11 +295,10 @@ func mountDriverHandlers(app *fiber.App, ds *driverState) { if err != nil { return c.SendStatus(fiber.StatusServiceUnavailable) } - if err := ds.rdb.Set(ctx, "pmsess:"+sid, buf, 10*time.Minute).Err(); err != nil { + if err := ds.rdb.Set(ctx, sessionKey, buf, 10*time.Minute).Err(); err != nil { return c.SendStatus(fiber.StatusServiceUnavailable) } - c.Cookie(&fiber.Cookie{Name: "pmsid", Value: sid, Path: "/", HTTPOnly: true}) c.Set("Content-Type", "application/json") return c.Send(mustJSON(sessionResponse{OK: true, Seq: newSeq})) }) diff --git a/servers/gin/driver_handlers.go b/servers/gin/driver_handlers.go index b3e12ca..0fc939c 100644 --- a/servers/gin/driver_handlers.go +++ b/servers/gin/driver_handlers.go @@ -2,8 +2,7 @@ package main import ( "context" - "crypto/rand" - "encoding/hex" + "encoding/json" "net/http" "os" "strconv" @@ -16,6 +15,11 @@ import ( "github.com/bradfitz/gomemcache/memcache" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // driverState holds the lazily-opened driver clients shared across the // four driver routes. Each client is opened once at registration from // the PROBATORIUM_* env vars the orchestrator injects; a nil client @@ -271,30 +275,47 @@ func (st *driverState) handleMC(c *gin.Context) { c.Data(http.StatusOK, "application/octet-stream", it.Value) } +// handleSession serves POST /session: GET the fixed-key blob (redis.Nil on +// the unseeded key is ignored), merge the JSON request body, bump the seq +// hit counter, then SET the blob back with a 10-minute TTL. Exactly two +// round-trips (GET then SET), the same workload every adapter runs — here +// over go-redis. No Redis -> 503. func (st *driverState) handleSession(c *gin.Context) { if st.redis == nil { c.AbortWithStatus(http.StatusServiceUnavailable) return } - var sid string - if ck, err := c.Request.Cookie(sessionCookieName); err == nil { - sid = ck.Value - } - if sid == "" { - sid = newSessionID() - http.SetCookie(c.Writer, &http.Cookie{ - Name: sessionCookieName, - Value: sid, - Path: "/", - HttpOnly: true, - }) - } ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() - key := "sess:" + sid - seq, _ := st.redis.Incr(ctx, key).Result() - st.redis.Expire(ctx, key, 10*time.Minute) - c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: int(seq)}) + + blob := map[string]any{} + if raw, err := st.redis.Get(ctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &blob) + } + + if body, _ := c.GetRawData(); len(body) > 0 { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err == nil { + for k, v := range payload { + blob[k] = v + } + } + } + + seq := 0 + if n, ok := blob["seq"].(float64); ok { // JSON numbers decode to float64 + seq = int(n) + } + seq++ + blob["seq"] = seq + + if raw, err := json.Marshal(blob); err == nil { + if err := st.redis.Set(ctx, sessionKey, raw, 10*time.Minute).Err(); err != nil { + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + } + c.JSON(http.StatusOK, sessionResponse{OK: true, Seq: seq}) } // userRow mirrors the users table row returned by GET /db/user/:id. @@ -310,10 +331,3 @@ type sessionResponse struct { OK bool `json:"ok"` Seq int `json:"seq"` } - -// newSessionID returns a random 16-byte hex session id. -func newSessionID() string { - var b [16]byte - _, _ = rand.Read(b[:]) - return hex.EncodeToString(b[:]) -} diff --git a/servers/hertz/driver_handlers.go b/servers/hertz/driver_handlers.go index b2aa0db..c136a07 100644 --- a/servers/hertz/driver_handlers.go +++ b/servers/hertz/driver_handlers.go @@ -10,15 +10,16 @@ import ( "github.com/bradfitz/gomemcache/memcache" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" - "github.com/cloudwego/hertz/pkg/protocol" "github.com/cloudwego/hertz/pkg/protocol/consts" - "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" goredis "github.com/redis/go-redis/v9" - - "github.com/goceleris/probatorium/servers/common" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // driverState holds the lazily-opened backend clients. Each field is nil // when the matching PROBATORIUM_* env var is unset; the handler then // answers 503 so loadgen counts errors deterministically rather than the @@ -73,7 +74,7 @@ func buildDriverState() *driverState { // mountDriverHandlers registers the 4 driver routes on h as native hertz // handlers. The workloads mirror the fiber/celeris reference: a PG row // read, Redis/memcached GETs returning the raw blob, and a session -// read-merge-write round-trip keyed on the pmsid cookie. +// load+merge+save round-trip on the fixed key pmsess:bench. func mountDriverHandlers(h *server.Hertz, ds *driverState) { h.GET("/db/user/:id", func(c context.Context, ctx *app.RequestContext) { if ds.pg == nil { @@ -126,9 +127,11 @@ func mountDriverHandlers(h *server.Hertz, ds *driverState) { }) h.POST("/session", func(c context.Context, ctx *app.RequestContext) { - // The session round-trip is Redis-backed (parity with celeris's - // redisstore). Without Redis there is nowhere to persist the blob, - // so the route is 503 — matching the driver-unavailable contract. + // Fixed-key round-trip over go-redis: GET the seeded blob (redis.Nil + // on the unseeded key is ignored), merge the JSON request body, bump + // the seq hit counter, then SET the blob back with a 10-minute TTL. + // Exactly two round-trips (GET then SET) — the same workload every + // adapter runs. No Redis -> 503. if ds.rdb == nil { ctx.AbortWithStatus(consts.StatusServiceUnavailable) return @@ -136,19 +139,9 @@ func mountDriverHandlers(h *server.Hertz, ds *driverState) { qctx, cancel := context.WithTimeout(c, 5*time.Second) defer cancel() - sid := string(ctx.Cookie(common.SessionCookieName)) data := make(map[string]any, 4) - if sid != "" { - raw, err := ds.rdb.Get(qctx, "pmsess:"+sid).Bytes() - if err == nil { - _ = json.Unmarshal(raw, &data) - } else if err != goredis.Nil { - ctx.AbortWithStatus(consts.StatusServiceUnavailable) - return - } - } - if sid == "" { - sid = uuid.NewString() + if raw, err := ds.rdb.Get(qctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &data) } // Merge the request body if it is JSON. Parse failures are @@ -172,12 +165,11 @@ func mountDriverHandlers(h *server.Hertz, ds *driverState) { ctx.AbortWithStatus(consts.StatusServiceUnavailable) return } - if err := ds.rdb.Set(qctx, "pmsess:"+sid, buf, 10*time.Minute).Err(); err != nil { + if err := ds.rdb.Set(qctx, sessionKey, buf, 10*time.Minute).Err(); err != nil { ctx.AbortWithStatus(consts.StatusServiceUnavailable) return } - ctx.SetCookie(common.SessionCookieName, sid, 0, "/", "", protocol.CookieSameSiteDisabled, false, true) ctx.Data(consts.StatusOK, "application/json", mustJSON(sessionResponse{OK: true, Seq: newSeq})) }) diff --git a/servers/iris/driver_handlers.go b/servers/iris/driver_handlers.go index 8ed6a22..e951f90 100644 --- a/servers/iris/driver_handlers.go +++ b/servers/iris/driver_handlers.go @@ -12,12 +12,14 @@ import ( "github.com/bradfitz/gomemcache/memcache" "github.com/jackc/pgx/v5/pgxpool" irisv12 "github.com/kataras/iris/v12" - "github.com/kataras/iris/v12/sessions" "github.com/redis/go-redis/v9" - - "github.com/goceleris/probatorium/servers/common" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // driverClients holds the lazily-constructed driver handles the four // driver scenarios round-trip against. A nil field means the service is // unconfigured (its env var was empty or the client failed to open), so @@ -25,14 +27,13 @@ import ( // deterministically rather than measuring a phantom 0-byte success. // // The iris adapter deliberately uses community-standard clients — pgx -// for Postgres, go-redis for Redis, gomemcache for memcached, and iris's -// own sessions package — so a chain cell reflects iris + idiomatic driver -// cost, never celeris in-tree drivers leaking into a competitor column. +// for Postgres, go-redis for Redis, gomemcache for memcached — so a chain +// cell reflects iris + idiomatic driver cost, never celeris in-tree +// drivers leaking into a competitor column. type driverClients struct { - pg *pgxpool.Pool - redis *redis.Client - mc *memcache.Client - sessions *sessions.Sessions + pg *pgxpool.Pool + redis *redis.Client + mc *memcache.Client } // Service-endpoint env vars. Mirrors the perfmatrix services.FromEnv @@ -53,7 +54,7 @@ type userRow struct { } // sessionResponse is the JSON body returned by POST /session; Seq is the -// per-cookie hit counter the session middleware increments each request. +// hit counter loaded from the fixed-key blob and bumped on every request. type sessionResponse struct { OK bool `json:"ok"` Seq int `json:"seq"` @@ -88,15 +89,6 @@ func newDriverClients() *driverClients { dc.mc = mc } - // The session store rides on Redis when it is configured; otherwise - // POST /session degrades to 503 like the other unconfigured routes. - if dc.redis != nil { - dc.sessions = sessions.New(sessions.Config{ - Cookie: common.SessionCookieName, - Expires: 10 * time.Minute, - }) - } - return dc } @@ -323,24 +315,46 @@ func mountDriverHandlers(app *irisv12.Application, dc *driverClients) { _ = c.JSON(sessionResponse{OK: true, Seq: len(items)}) }) - // Session: POST /session — iris's own session middleware, started - // inline so the hit counter only fires on this route. + // Session: POST /session — fixed-key round-trip over go-redis directly + // (no iris sessions library), so the workload matches every adapter: + // GET the fixed-key blob (redis.Nil on the unseeded key is ignored), + // merge the JSON request body, bump the seq hit counter, then SET the + // blob back with a 10-minute TTL. Exactly two round-trips (GET then SET). app.Post("/session", func(c irisv12.Context) { - if dc.sessions == nil { + if dc.redis == nil { c.StopWithStatus(http.StatusServiceUnavailable) return } - sess := dc.sessions.Start(c) - body, _ := io.ReadAll(c.Request().Body) - if len(body) > 0 { + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + + blob := map[string]any{} + if raw, err := dc.redis.Get(ctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &blob) + } + + if body, _ := io.ReadAll(c.Request().Body); len(body) > 0 { var payload map[string]any if err := json.Unmarshal(body, &payload); err == nil { for k, v := range payload { - sess.Set(k, v) + blob[k] = v } } } - seq := sess.Increment("seq", 1) + + seq := 0 + if n, ok := blob["seq"].(float64); ok { // JSON numbers decode to float64 + seq = int(n) + } + seq++ + blob["seq"] = seq + + if raw, err := json.Marshal(blob); err == nil { + if err := dc.redis.Set(ctx, sessionKey, raw, 10*time.Minute).Err(); err != nil { + c.StopWithStatus(http.StatusServiceUnavailable) + return + } + } c.ContentType("application/json") _ = c.JSON(sessionResponse{OK: true, Seq: seq}) }) @@ -362,5 +376,4 @@ func closeDriverClients(dc *driverClients) { dc.redis = nil } dc.mc = nil - dc.sessions = nil } diff --git a/servers/stdhttp/driver_handlers.go b/servers/stdhttp/driver_handlers.go index b8e31d3..a694845 100644 --- a/servers/stdhttp/driver_handlers.go +++ b/servers/stdhttp/driver_handlers.go @@ -6,8 +6,8 @@ // - GET /db/user/{id} — jackc/pgx/v5 pool, SELECT by id, JSON row. // - GET /cache/{key} — redis/go-redis/v9, GET raw bytes. // - GET /mc/{key} — bradfitz/gomemcache, GET raw bytes. -// - POST /session — go-redis-backed session: load/merge/save a hit -// counter keyed by the pmsid cookie, JSON {ok,seq} reply. +// - POST /session — go-redis GET+SET round-trip on the fixed key +// pmsess:bench: load/merge/save a hit counter, JSON {ok,seq} reply. // // This is the chi adapter's driver layer with chi.URLParam swapped for // ServeMux's r.PathValue and chi.Router swapped for *http.ServeMux — the @@ -25,8 +25,6 @@ package main import ( "context" - "crypto/rand" - "encoding/hex" "encoding/json" "errors" "io" @@ -40,6 +38,11 @@ import ( "github.com/redis/go-redis/v9" ) +// sessionKey is the fixed key POST /session round-trips against, so the +// workload is a load+merge+save of one seeded blob — identical to the +// other adapters. +const sessionKey = "pmsess:bench" + // userRow mirrors the seeded users table row (id, name, email, score), // matching the celeris reference's userRow so the JSON body is identical // across adapters. @@ -51,8 +54,8 @@ type userRow struct { } // sessionResponse is the JSON body returned by POST /session. seq is the -// session's hit counter, incremented on every request carrying the same -// pmsid cookie. Shape matches the celeris reference. +// hit counter loaded from the fixed-key blob and bumped on every request. +// Shape matches the celeris reference. type sessionResponse struct { OK bool `json:"ok"` Seq int `json:"seq"` @@ -206,15 +209,11 @@ func (c *driverClients) mcHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(item.Value) } -// sessionHandler serves POST /session with a Redis-backed session store, -// mirroring the celeris reference's redisstore session: load the blob by -// the pmsid cookie, merge any JSON request body, bump the seq hit -// counter, persist, and reply {ok,seq}. No Redis -> 503. -// -// The store is a plain Redis JSON blob under "sess:" with a 10-minute -// TTL (the reference's IdleTimeout). A loadgen client that reuses the -// cookie observes a monotonically increasing seq, proving the store -// round-trip; a fresh client starts at 1. +// sessionHandler serves POST /session over a Redis-backed JSON blob on the +// fixed key pmsess:bench: GET the blob (redis.Nil on the unseeded key is +// ignored), merge any JSON request body, bump the seq hit counter, then SET +// the blob back with a 10-minute TTL. Exactly two round-trips (GET then SET) +// — the same workload every adapter runs, over go-redis here. No Redis -> 503. func (c *driverClients) sessionHandler(w http.ResponseWriter, r *http.Request) { if c.redis == nil { w.WriteHeader(http.StatusServiceUnavailable) @@ -223,14 +222,9 @@ func (c *driverClients) sessionHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - sid, fresh := sessionID(r) - key := "sess:" + sid - blob := map[string]any{} - if !fresh { - if raw, err := c.redis.Get(ctx, key).Bytes(); err == nil { - _ = json.Unmarshal(raw, &blob) - } + if raw, err := c.redis.Get(ctx, sessionKey).Bytes(); err == nil { + _ = json.Unmarshal(raw, &blob) } // Merge a JSON request body if present (the scenario POSTs a ~256B @@ -252,17 +246,12 @@ func (c *driverClients) sessionHandler(w http.ResponseWriter, r *http.Request) { blob["seq"] = seq if raw, err := json.Marshal(blob); err == nil { - _ = c.redis.Set(ctx, key, raw, 10*time.Minute).Err() + if err := c.redis.Set(ctx, sessionKey, raw, 10*time.Minute).Err(); err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } } - if fresh { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: sid, - Path: "/", - HttpOnly: true, - }) - } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(sessionResponse{OK: true, Seq: seq}) @@ -442,19 +431,6 @@ func (c *driverClients) mcMultiGetHandler(w http.ResponseWriter, r *http.Request _ = json.NewEncoder(w).Encode(sessionResponse{OK: true, Seq: len(items)}) } -// sessionID returns the session id from the pmsid cookie, or a freshly -// generated one (with fresh=true so the caller emits a Set-Cookie). -func sessionID(r *http.Request) (id string, fresh bool) { - if ck, err := r.Cookie(sessionCookieName); err == nil && ck.Value != "" { - return ck.Value, false - } - var b [16]byte - if _, err := rand.Read(b[:]); err != nil { - return strconv.FormatInt(time.Now().UnixNano(), 16), true - } - return hex.EncodeToString(b[:]), true -} - // Compile-time guard so the errors import stays tied to real behavior even // if a future edit drops its only use. var _ = errors.New diff --git a/services/services.go b/services/services.go index e684b2a..8b2d230 100644 --- a/services/services.go +++ b/services/services.go @@ -39,6 +39,10 @@ const ( // v1.5.4 driver-depth fixtures. FixtureWritesTable = "bench_writes" // unlogged PG table for driver-pg-write FixtureRedisWriteKey = "demo-write" // key driver-redis-set writes (no seed; the bench writes it) + + // FixtureSessionKey is the fixed key driver-session-rw GETs+SETs. Seeded + // with a small JSON blob so the handler's read hits a populated key. + FixtureSessionKey = "pmsess:bench" ) // Kind enumerates the services probatorium can provision. String values @@ -56,6 +60,18 @@ const ( imageMemcached = "memcached:1.6.41-alpine" ) +// sessionSeed is the initial value written to FixtureSessionKey: a ~256B +// JSON-ish blob padded with filler so driver-session-rw's GET hits a +// populated key sized like a real session record. +var sessionSeed = func() []byte { + const size = 256 + b := []byte(`{"seq":0,"uid":42,"pad":"`) + for len(b) < size-2 { + b = append(b, 'x') + } + return append(b, '"', '}') +}() + // Handles is the set of running services returned by Start. Fields are nil // when the corresponding service was not requested. Driver scenarios read // DSNs/addresses off the Handles to configure their clients. @@ -235,6 +251,9 @@ func startPostgres(ctx context.Context) (*PGService, error) { "-e", "POSTGRES_DB=bench", "-p", "127.0.0.1:0:5432/tcp", imagePostgres, + // Disable WAL fsync-on-commit so driver-pg-update-tx measures + // driver/framework overhead, not the host's disk fsync rate. + "-c", "synchronous_commit=off", ) if err != nil { return nil, err @@ -530,6 +549,10 @@ func seedRedis(ctx context.Context, addr string) error { return err } + if err := rdb.Set(ctx, FixtureSessionKey, sessionSeed, 0).Err(); err != nil { + return err + } + blob := bytes.Repeat([]byte("s"), 256) for i := FixtureSessionIDMin; i <= FixtureSessionIDMax; i++ { key := fmt.Sprintf("user:%d:session", i) From a418360903626298f524d192c7bc8a288bab72c0 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Tue, 23 Jun 2026 02:21:48 +0200 Subject: [PATCH 08/11] =?UTF-8?q?bench(v1.5.4):=20pre-launch=20audit=20rou?= =?UTF-8?q?nd=202=20=E2=80=94=20ranking=20gate,=20rated=20pin,=20seed=20gu?= =?UTF-8?q?ard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second adversarial pre-run audit. Verified the headline-ranking findings only reach a rated/publish run (the saturation-only Fast matrix renders no LatencyAtSLO table), then fixed four real issues + a stale test: 1. HEADLINE RANKING now consults NetworkBound. writeLatencyAtSLOSection bolded a per-column leader for every scenario carrying rated data, including the wire-bound post-1m (the NetworkBound flag set in BuildDocument was never read by the ranking), the tick-bound fan-out cells (ws-hub-broadcast-*, sse-fanout-*, paced by the 1ms publish tick), and the single-conn latency probe get-json-1c (RPS == 1/latency). New isFanoutBound + isLatencyProbeByDesign predicates + a headlineRanked() gate drop them from the bolded headline (they stay in the detail/tail-latency/network-bound sections) and disclose the exclusion in a note. Added TestHeadlineExcludesNonCPUBoundScenarios. 2. RATED PIN reconciled: budget.RatedScenarios listed auto-mix-111, which is never registered, so the -cells filter matched nothing and the rated grid was 16 cells while Headline/FullRatedRealizedCells pinned 24. Removed the dead entry, re-pinned 24->16, added TestRatedRealizedCellsMatchSubset. 3. SEED GUARD: services.VerifySeed reads back a canonical fixture from each backend after seeding (users row count, redis/mc demo-key + session keys) and errors on the seed step if any is missing — turning a silent partial or mis-targeted seed (which would publish a no-work driver cell as a fast 200) into a loud failure before any cell runs. Wired into Seed + SeedExternal. 4. ENV: validation/refapp/driver_memcached read PROBATORIUM_MC_ADDR while ansible/validate.yml exports PROBATORIUM_MEMCACHED_ADDR (dead override, masked by a matching default) — unified to PROBATORIUM_MEMCACHED_ADDR. Also fixed the stale TestCellsGlobServersRespectsExcludes want list (the v1.5.4 celeris-column expansion grew celeris-* 4->9 engine modes; the test still hardcoded 4 and failed under -tags mage) and the stale 60s/15s headline-window docstring in mage_tier.go (now 40s/12s). Root + adapter modules build; report/budget/services tests + the -tags mage grid guards pass. --- budget/budget_test.go | 25 +++++++++ budget/profiles.go | 12 ++-- mage_bench_cellsglob_test.go | 18 +++++- mage_tier.go | 16 +++--- report/document.go | 29 ++++++++++ report/markdown.go | 64 +++++++++++++++++++++- report/netbound_test.go | 40 ++++++++++++++ services/services.go | 57 ++++++++++++++++++- services/verifyseed_test.go | 18 ++++++ validation/refapp/driver_memcached/main.go | 7 ++- 10 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 services/verifyseed_test.go diff --git a/budget/budget_test.go b/budget/budget_test.go index eb1a049..6a85a9f 100644 --- a/budget/budget_test.go +++ b/budget/budget_test.go @@ -163,6 +163,31 @@ func TestGlobsAreNonEmpty(t *testing.T) { } } +// TestRatedRealizedCellsMatchSubset guards the rated-pin against the +// auto-mix-111 class of drift: the rated sweep runs RatedScenarios x +// RatedServers, and every curated rated scenario is a plain-H1 static row +// that applies to every curated rated server, so the realized count is the +// full cross product with no capability-gating loss. If a stale entry +// (an unregistered scenario whose glob matches nothing) sneaks back into +// RatedScenarios, the cross-product pin and this assertion diverge from +// reality — fail here rather than silently shrinking the published rated +// grid while the budget over-projects. +func TestRatedRealizedCellsMatchSubset(t *testing.T) { + want := len(RatedScenarios) * len(RatedServers) + if HeadlineRatedRealizedCells != want { + t.Errorf("HeadlineRatedRealizedCells = %d, want %d (len(RatedScenarios)=%d * len(RatedServers)=%d); "+ + "a mismatch means a rated scenario is unregistered or the pin is stale", + HeadlineRatedRealizedCells, want, len(RatedScenarios), len(RatedServers)) + } + if FullRatedRealizedCells != want { + t.Errorf("FullRatedRealizedCells = %d, want %d", FullRatedRealizedCells, want) + } + if len(ratedGlobs()) != want { + t.Errorf("len(ratedGlobs()) = %d, want %d (the expanded glob set must match the pin)", + len(ratedGlobs()), want) + } +} + // TestColumnWallClock pins the per-column projection the ansible hang // guard is sized from. The "v3.8 rated column" case uses the REAL run // config (33 capability-gated scenarios on celeris-epoll-h1-sync, diff --git a/budget/profiles.go b/budget/profiles.go index 0b7eb51..ddaa26b 100644 --- a/budget/profiles.go +++ b/budget/profiles.go @@ -21,11 +21,15 @@ import "time" // because it is the expensive additive dimension — see RatedServers below. // RatedScenarios is the curated rated/SLO subset (#156): the SLO-knee -// scenarios where throughput-at-SLO carries the most signal. +// scenarios where throughput-at-SLO carries the most signal. Kept to the +// two registered static rows that every rated server runs — a GET read and +// a 4 KiB POST. (A third entry, "auto-mix-111", was listed here but never +// registered, so the -cells filter silently matched nothing and the rated +// grid was 16 cells while the pin claimed 24; removed in the v1.5.4 pre-run +// audit. Re-add an auto-mix scenario here only once it is registered.) var RatedScenarios = []string{ "get-json", "post-4k", - "auto-mix-111", } // RatedServers is the curated rated column subset: the four celeris modes @@ -54,7 +58,7 @@ var RatedServers = []string{ // rated sweep stays curated, so HeadlineRatedRealizedCells is unchanged. const ( HeadlineRealizedCells = FullRealizedCells - HeadlineRatedRealizedCells = 24 // 8 rated servers x 3 rated scenarios, capability-gated + HeadlineRatedRealizedCells = 16 // 8 rated servers x 2 rated scenarios, capability-gated // Full profile: every server x every scenario, capability-gated. This is // the SAME realized "*/*" grid Fast runs (FullRealizedCells == @@ -67,7 +71,7 @@ const ( // `cmd/runner -dry-run -cells '*/*' | grep -c '^run0'` when the registry // changes; the grid is now 52 columns x 29 rows, capability-gated. FullRealizedCells = 835 - FullRatedRealizedCells = 24 + FullRatedRealizedCells = 16 ) // HeadlineWeekly is the config the benchmark-tier workflow runs on the diff --git a/mage_bench_cellsglob_test.go b/mage_bench_cellsglob_test.go index 71b81e2..fa763f1 100644 --- a/mage_bench_cellsglob_test.go +++ b/mage_bench_cellsglob_test.go @@ -83,23 +83,30 @@ func TestCellsGlobServersWildcardUsesFullRegistry(t *testing.T) { // the exclude). // - the full negative "get-*/celeris-std-h1" then // "!get-*/celeris-std-h1" leaves an empty include glob → fallback -// to "*", which keeps the 4 celeris servers via the implicit +// to "*", which keeps the celeris servers via the implicit // include from the empty include. Documented behaviour: empty // include with all excludes = "use the registry, but respect the // excludes against it" — same as the runner. func TestCellsGlobServersRespectsExcludes(t *testing.T) { // Case 1: include "get-*/celeris-*" + exclude "get-simple/celeris-std-h1". - // The 4 celeris servers all match at least one (get-json etc.), so + // Every celeris engine column matches at least one (get-json etc.), so // celeris-std-h1 stays (its get-json/celeris-std-h1 pair still - // matches the include, and no exclude covers the whole server). + // matches the include, and no exclude covers the whole server). The + // want set is the FULL celeris-* column family (the v1.5.4 redesign + // expanded it from 4 to the 9 engine modes below). got, err := cellsGlobServers("get-*/celeris-*,!get-simple/celeris-std-h1") if err != nil { t.Fatalf("cellsGlobServers: %v", err) } want := []string{ + "celeris-adaptive-auto+upg-async", + "celeris-adaptive-h1-async", + "celeris-epoll-auto+upg-async", + "celeris-epoll-h1-async", "celeris-epoll-h1-sync", "celeris-iouring-auto+upg-async", "celeris-iouring-h1-async", + "celeris-iouring-h1-sync", "celeris-std-h1", } if !reflect.DeepEqual(got, want) { @@ -114,9 +121,14 @@ func TestCellsGlobServersRespectsExcludes(t *testing.T) { t.Fatalf("cellsGlobServers: %v", err) } want = []string{ + "celeris-adaptive-auto+upg-async", + "celeris-adaptive-h1-async", + "celeris-epoll-auto+upg-async", + "celeris-epoll-h1-async", "celeris-epoll-h1-sync", "celeris-iouring-auto+upg-async", "celeris-iouring-h1-async", + "celeris-iouring-h1-sync", "celeris-std-h1", } if !reflect.DeepEqual(got, want) { diff --git a/mage_tier.go b/mage_tier.go index f78c80e..6110bc6 100644 --- a/mage_tier.go +++ b/mage_tier.go @@ -30,12 +30,12 @@ import ( // // Per-cell execution: a cell visits ONE (server, scenario) pair and // runs the saturation pass unconditionally. If the scenario is in -// the rated subset (currently get-json / post-4k / auto-mix-111) and -// the runner is launched with BENCH_RATED=1, the same cell ALSO runs -// the rated sweep after the saturation pass. The cell's JSON carries -// both maps on the same row; the bench's published Document has a -// per-scenario SaturationModeRPS (every scenario) + a per-scenario -// LatencyAtSLO (only the rated 3). +// the rated subset (currently get-json / post-4k) and the runner is +// launched with BENCH_RATED=1, the same cell ALSO runs the rated +// sweep after the saturation pass. The cell's JSON carries both maps +// on the same row; the bench's published Document has a per-scenario +// SaturationModeRPS (every scenario) + a per-scenario LatencyAtSLO +// (only the rated 2). // // Flow: // @@ -57,8 +57,8 @@ import ( // grid (every server × every scenario, // capability-gated); they differ ONLY by the // per-cell window. headline (the weekly -// cadence) uses 60s/15s so the whole grid fits -// 24h single-arch (~21.6h); full uses 90s/20s +// cadence) uses 40s/12s so the whole grid fits +// 24h single-arch (~16.7h); full uses 90s/20s // for the exhaustive sweep (~30h on one arch, // needs a raised BENCH_BUDGET). Default: full. // BENCH_TARGET=both msa2-server | msr1 | both (both = 2 arches) diff --git a/report/document.go b/report/document.go index 24ab0d2..73ebd04 100644 --- a/report/document.go +++ b/report/document.go @@ -2,6 +2,7 @@ package report import ( "sort" + "strings" "time" ) @@ -254,6 +255,34 @@ func isWireBoundByDesign(scenarioName string) bool { return scenarioName == "post-1m" } +// isFanoutBound reports whether a scenario's throughput is paced by the +// server's fixed publish tick rather than by CPU. The hub-broadcast and +// SSE-fanout cells push to N subscribers on a 1 ms cadence, so their RPS +// ceiling is ~1000*N regardless of server headroom — a fan-out rate, not a +// throughput the field can be ranked by. Their real signal is delivery +// latency (the tail-latency section), so they are kept out of the headline +// ranking just like the wire-bound cells. The echo modes (ws-echo / +// ws-large-echo) are client-driven round-trips and stay ranked. +func isFanoutBound(scenarioName string) bool { + switch scenarioName { + case "ws-hub-broadcast-128", "ws-hub-broadcast-1024", + "sse-fanout-128", "sse-fanout-1024": + return true + } + return false +} + +// isLatencyProbeByDesign reports whether a scenario is a single-connection +// latency probe whose saturation "RPS" is a latency reciprocal (1/RTT) +// rather than a throughput. At one connection requests serialize, so the +// number rewards low per-request latency, not throughput, and must never +// head a raw-RPS ranking — its real signal is the tail-latency section. The +// "-1c" suffix is the single-conn marker (scenarios.ProfileSingle); +// get-json-1c is the only such scenario today. +func isLatencyProbeByDesign(scenarioName string) bool { + return strings.HasSuffix(scenarioName, "-1c") +} + // isNetworkBound reports whether a cell's achieved egress bandwidth sat at // the fabric line rate (NIC-limited) rather than the server's CPU limit. // bytesPerSec is the median across-runs throughput; loadgenCPUP95 is the diff --git a/report/markdown.go b/report/markdown.go index e413808..63c1de0 100644 --- a/report/markdown.go +++ b/report/markdown.go @@ -146,8 +146,32 @@ func writeLatencyAtSLOSection(w io.Writer, doc *Document) error { return err } - scenarios := scenariosFromDoc(doc) + // Only scenarios whose max-sustained-RPS is a server-CPU-bound + // throughput belong in the headline ranking (it crowns a per-column + // leader). Wire-bound (post-1m), fan-out (ws-hub/sse-fanout) and + // single-conn latency-probe (get-json-1c) cells are dropped here — they + // remain in the detail, tail-latency and network-bound sections — and + // disclosed in a note so the table is honest about what it excludes. + all := scenariosFromDoc(doc) + scenarios := make([]string, 0, len(all)) + var excluded []string + for _, sc := range all { + if headlineRanked(doc, sc) { + scenarios = append(scenarios, sc) + } else { + excluded = append(excluded, sc) + } + } sort.Strings(scenarios) + sort.Strings(excluded) + if len(excluded) > 0 { + if _, err := fmt.Fprintf(w, + "_Not ranked here — saturation RPS for these is not server-CPU-bound "+ + "(wire-bound, fan-out, or single-conn latency probe); see the tail-latency "+ + "and network-bound sections: %s._\n\n", strings.Join(excluded, ", ")); err != nil { + return err + } + } // Sort adapters by Name for stable rendering. adapters := make([]ServerResult, len(doc.Benchmarks)) @@ -821,6 +845,44 @@ func scenariosFromDoc(doc *Document) []string { return out } +// headlineRanked reports whether a scenario belongs in the headline +// Latency-at-SLO ranking. The headline bolds a per-column leader, so it +// must contain only scenarios whose max-sustained-RPS is a server-CPU- +// bound throughput the field can be fairly ranked by. A scenario is +// excluded when its number is bound by something other than the server: +// +// - wire-bound by design (post-1m) OR runtime network-bound for every +// data-bearing adapter: the RPS sat at the fabric line rate and +// converged across fast adapters, so the honest comparison is the +// CPU-efficiency table (writeNetworkBoundSection), not a bolded leader +// here. Without this check the NetworkBound flag set in BuildDocument +// was never consulted by the headline ranking. +// - fan-out cells (ws-hub-broadcast-*, sse-fanout-*): paced by the +// server's fixed 1 ms publish tick, not CPU. +// - single-connection latency probes (get-json-1c): RPS == 1/latency. +// +// Excluded scenarios still appear in the detail, tail-latency and +// network-bound sections; they are dropped only from the bolded headline. +func headlineRanked(doc *Document, sc string) bool { + if isFanoutBound(sc) || isLatencyProbeByDesign(sc) { + return false + } + // Network-bound (by design OR runtime measurement): excluded only when + // it held for EVERY adapter that produced rated data for this scenario + // — a split result (bound for some, CPU-limited for others) still ranks. + dataBearing, bound := 0, 0 + for _, a := range doc.Benchmarks { + if _, ok := a.LatencyAtSLO[sc]; !ok { + continue + } + dataBearing++ + if isWireBoundByDesign(sc) || a.NetworkBound[sc] { + bound++ + } + } + return dataBearing == 0 || bound < dataBearing +} + // groupByCategory buckets aggregates by Scenario category. Aggregates // that lack a category are bucketed under "other". Non-OK cells (N/A / // DNF) are skipped: the detail table reports real measurements, so a diff --git a/report/netbound_test.go b/report/netbound_test.go index 907043c..a85238f 100644 --- a/report/netbound_test.go +++ b/report/netbound_test.go @@ -127,3 +127,43 @@ func TestNetworkBoundMarkdownSection(t *testing.T) { t.Errorf("expected celeris ranked above fastapi in NIC-bound table (eff=%d ineff=%d)", effIdx, ineffIdx) } } + +// TestHeadlineExcludesNonCPUBoundScenarios pins the headline ranking gate: +// a scenario whose saturation RPS is not server-CPU-bound — wire-bound by +// design (post-1m), fan-out (ws-hub/sse-fanout), or a single-conn latency +// probe (get-json-1c) — must NOT head a bolded Latency-at-SLO table, while a +// genuine CPU-bound row (get-json) still does. This is the consult of the +// NetworkBound flag that the headline ranking previously ignored. +func TestHeadlineExcludesNonCPUBoundScenarios(t *testing.T) { + t.Parallel() + slo := map[int]int{10: 1000, 50: 2000, 100: 3000, 500: 4000, 1000: 5000} + doc := &Document{ + SchemaVersion: SchemaVersion, + Benchmarks: []ServerResult{{ + Name: "celeris-iouring-h1-async", + LatencyAtSLO: map[string]map[int]int{ + "get-json": slo, // CPU-bound → ranked + "post-1m": slo, // wire-bound by design → excluded + "get-json-1c": slo, // single-conn latency probe → excluded + "ws-hub-broadcast-128": slo, // fan-out → excluded + }, + NetworkBound: map[string]bool{"post-1m": true}, + }}, + } + var buf bytes.Buffer + if err := writeLatencyAtSLOSection(&buf, doc); err != nil { + t.Fatalf("writeLatencyAtSLOSection: %v", err) + } + out := buf.String() + if !strings.Contains(out, "### get-json\n") { + t.Error("get-json (CPU-bound) must head a headline ranking table") + } + for _, sc := range []string{"### post-1m", "### get-json-1c", "### ws-hub-broadcast-128"} { + if strings.Contains(out, sc) { + t.Errorf("%q must NOT head a headline ranking table", sc) + } + } + if !strings.Contains(out, "Not ranked here") { + t.Error("excluded scenarios should be disclosed in a note") + } +} diff --git a/services/services.go b/services/services.go index 8b2d230..7c263f7 100644 --- a/services/services.go +++ b/services/services.go @@ -208,6 +208,51 @@ func SeedExternal(ctx context.Context, pgDSN, redisAddr, mcAddr string) error { return fmt.Errorf("seed memcached %s: %w", mcAddr, err) } } + return VerifySeed(ctx, pgDSN, redisAddr, mcAddr) +} + +// VerifySeed reads back a representative fixture from each configured +// backend and errors if any canonical key/row is missing. It is the +// post-seed guard that turns a silent partial or mis-targeted seed — the +// dominant cause of a driver cell that publishes a fast 200 while doing no +// real work, since a cache miss / empty range is a cheap, healthy-looking +// response that the per-cell classifier cannot tell from genuine throughput +// — into a loud failure on the seed step, before any bench cell runs. An +// empty address skips that backend, mirroring SeedExternal. +func VerifySeed(ctx context.Context, pgDSN, redisAddr, mcAddr string) error { + if pgDSN != "" { + conn, err := pgx.Connect(ctx, pgDSN) + if err != nil { + return fmt.Errorf("verify seed: connect postgres %s: %w", pgDSN, err) + } + defer func() { _ = conn.Close(ctx) }() + var n int + if err := conn.QueryRow(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil { + return fmt.Errorf("verify seed: count users: %w", err) + } + if n < FixtureUserMaxID { + return fmt.Errorf("verify seed: users has %d rows, want >= %d (seed did not populate the bench backend)", n, FixtureUserMaxID) + } + } + if redisAddr != "" { + rdb := redis.NewClient(&redis.Options{Addr: redisAddr}) + defer func() { _ = rdb.Close() }() + for _, k := range []string{FixtureDemoKey, FixtureSessionKey, "user:1:session"} { + v, err := rdb.Get(ctx, k).Result() + if err != nil || len(v) == 0 { + return fmt.Errorf("verify seed: redis key %q missing/empty (err=%v); seed did not reach %s", k, err, redisAddr) + } + } + } + if mcAddr != "" { + mc := memcache.New(mcAddr) + for _, k := range []string{FixtureDemoKey, "user:1:session"} { + it, err := mc.Get(k) + if err != nil || it == nil || len(it.Value) == 0 { + return fmt.Errorf("verify seed: memcached key %q missing/empty (err=%v); seed did not reach %s", k, err, mcAddr) + } + } + } return nil } @@ -239,7 +284,17 @@ func (h *Handles) Seed(ctx context.Context) error { return fmt.Errorf("seed memcached: %w", err) } } - return nil + var pgDSN, redisAddr, mcAddr string + if h.Postgres != nil { + pgDSN = h.Postgres.DSN + } + if h.Redis != nil { + redisAddr = h.Redis.Addr + } + if h.Memcached != nil { + mcAddr = h.Memcached.Addr + } + return VerifySeed(ctx, pgDSN, redisAddr, mcAddr) } // startPostgres runs a Postgres container on a random loopback port and diff --git a/services/verifyseed_test.go b/services/verifyseed_test.go new file mode 100644 index 0000000..5d341e9 --- /dev/null +++ b/services/verifyseed_test.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + "testing" +) + +// TestVerifySeedNoBackendsNoop pins the empty-address contract: a Go-only +// bench with no driver backends provisioned must verify cleanly (every +// address empty → every backend skipped), mirroring SeedExternal's no-op +// path. The populated-backend assertions live behind the integration +// harness that has live postgres/redis/memcached. +func TestVerifySeedNoBackendsNoop(t *testing.T) { + t.Parallel() + if err := VerifySeed(context.Background(), "", "", ""); err != nil { + t.Errorf("VerifySeed with no addresses should be a no-op, got %v", err) + } +} diff --git a/validation/refapp/driver_memcached/main.go b/validation/refapp/driver_memcached/main.go index 426936f..053ef6a 100644 --- a/validation/refapp/driver_memcached/main.go +++ b/validation/refapp/driver_memcached/main.go @@ -12,7 +12,8 @@ // - Set + Get happy path on the seeded demo-key. // - CAS update + read-after-write consistency (I-DRV-1). // -// Address is taken from -mc-addr or PROBATORIUM_MC_ADDR. +// Address is taken from -mc-addr or PROBATORIUM_MEMCACHED_ADDR (the +// canonical var the bench adapters and ansible/validate.yml export). // // On startup the refapp prints the canonical ready line: // @@ -54,8 +55,8 @@ func envOr(key, def string) string { func main() { bind := flag.String("bind", "127.0.0.1:8080", "address:port to listen on") engineFlag := flag.String("engine", "auto", "engine: iouring | epoll | std | adaptive | auto") - addr := flag.String("mc-addr", envOr("PROBATORIUM_MC_ADDR", "127.0.0.1:21211"), - "memcached host:port; env: PROBATORIUM_MC_ADDR") + addr := flag.String("mc-addr", envOr("PROBATORIUM_MEMCACHED_ADDR", "127.0.0.1:21211"), + "memcached host:port; env: PROBATORIUM_MEMCACHED_ADDR") rps := flag.Float64("rps", 5000, "ratelimit RPS per key (permissive for walker traffic)") burst := flag.Int("burst", 1000, "ratelimit burst per key") flag.Parse() From 31292605bdfaeb9c8a44ee7da86890ab9bdf79cc Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Tue, 23 Jun 2026 10:59:22 +0200 Subject: [PATCH 09/11] bench(v1.5.4): finish stale-window docstring + auto-mix comment cleanup Follow-up to a418360 (the round-2 audit fixes): - budget.go ForProfile docstring still claimed the headline window was 60s/15s; corrected to 40s/12s to match HeadlineWeekly() and mage_tier.go. - Reword the RatedScenarios comment to reflect auto-mix-111 was deleted for good (not 'auto-mix-111 ... re-add once registered'), per owner decision that the scenario is gone. The dead entry was already removed from the rated pass in a418360. --- budget/budget.go | 2 +- budget/profiles.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/budget/budget.go b/budget/budget.go index 23e592b..a4b5aea 100644 --- a/budget/budget.go +++ b/budget/budget.go @@ -222,7 +222,7 @@ func plural(n int) string { // Profile. Both "headline" and "full" now cover the SAME grid — every // registered server × every scenario, capability-gated (Globs "*/*") — // so NEITHER silently drops servers or scenarios. They differ only by the -// per-cell window: "headline" (the weekly cadence) uses a shorter 60s/15s +// per-cell window: "headline" (the weekly cadence) uses a shorter 40s/12s // window so the whole grid fits the 24h budget single-arch; "full" uses a // longer 90s/20s window for the occasional exhaustive sweep (over 24h, // run as a manual dispatch with a raised BENCH_BUDGET). The default (empty diff --git a/budget/profiles.go b/budget/profiles.go index ddaa26b..47bff8d 100644 --- a/budget/profiles.go +++ b/budget/profiles.go @@ -21,12 +21,12 @@ import "time" // because it is the expensive additive dimension — see RatedServers below. // RatedScenarios is the curated rated/SLO subset (#156): the SLO-knee -// scenarios where throughput-at-SLO carries the most signal. Kept to the -// two registered static rows that every rated server runs — a GET read and -// a 4 KiB POST. (A third entry, "auto-mix-111", was listed here but never -// registered, so the -cells filter silently matched nothing and the rated -// grid was 16 cells while the pin claimed 24; removed in the v1.5.4 pre-run -// audit. Re-add an auto-mix scenario here only once it is registered.) +// scenarios where throughput-at-SLO carries the most signal. The two +// registered static rows every rated server runs — a GET read and a 4 KiB +// POST. (A third entry, "auto-mix-111", used to be listed here but the +// scenario was deleted and never registered, so the -cells filter silently +// matched nothing and the rated grid was 16 cells while the pin claimed 24; +// removed from the rated pass in the v1.5.4 pre-run audit.) var RatedScenarios = []string{ "get-json", "post-4k", From dc645291097aa3ce70ea3be1a0997c54eb41bd05 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Tue, 23 Jun 2026 12:47:31 +0200 Subject: [PATCH 10/11] deps: pin celeris v1.5.4 + loadgen v1.4.10 across adapter + refapps --- go.mod | 2 +- go.sum | 4 ++-- servers/celeris/go.mod | 2 +- servers/celeris/go.sum | 4 ++-- validation/refapp/auth_jwt_csrf/go.mod | 2 +- validation/refapp/auth_jwt_csrf/go.sum | 4 ++-- validation/refapp/auth_session_ratelimit/go.mod | 2 +- validation/refapp/auth_session_ratelimit/go.sum | 4 ++-- validation/refapp/driver_memcached/go.mod | 2 +- validation/refapp/driver_memcached/go.sum | 4 ++-- validation/refapp/driver_postgres/go.mod | 2 +- validation/refapp/driver_postgres/go.sum | 4 ++-- validation/refapp/driver_redis/go.mod | 2 +- validation/refapp/driver_redis/go.sum | 4 ++-- validation/refapp/kitchen_sink/go.mod | 2 +- validation/refapp/kitchen_sink/go.sum | 4 ++-- validation/refapp/observability/go.mod | 6 +++--- validation/refapp/observability/go.sum | 12 ++++++------ validation/refapp/static_swagger_proxy/go.mod | 2 +- validation/refapp/static_swagger_proxy/go.sum | 4 ++-- 20 files changed, 36 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 140e680..da5efa7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.4 require ( github.com/HdrHistogram/hdrhistogram-go v1.2.0 github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c - github.com/goceleris/loadgen v1.4.9 + github.com/goceleris/loadgen v1.4.10 github.com/google/gofuzz v1.2.0 github.com/jackc/pgx/v5 v5.10.0 github.com/pierrec/lz4/v4 v4.1.27 diff --git a/go.sum b/go.sum index b99101f..587c434 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/goceleris/loadgen v1.4.9 h1:Kd/AmLHP520Su3azQ9tCNoc6tsaeEf7Nx8ECr4AdYfg= -github.com/goceleris/loadgen v1.4.9/go.mod h1:Olg2awQufUnRemRlCvFPFL6Ww3byUd+UvZYQAMJm6Co= +github.com/goceleris/loadgen v1.4.10 h1:j8qi6xQK4Bk1AqObhHBTbE1ZsG+s3vbpinLE4uPSfCI= +github.com/goceleris/loadgen v1.4.10/go.mod h1:9LvtFtzoZj8z3MkE4lFvOQ3VZt4jBBB0b/TXxfieIGA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= diff --git a/servers/celeris/go.mod b/servers/celeris/go.mod index a46cc7a..f2340f0 100644 --- a/servers/celeris/go.mod +++ b/servers/celeris/go.mod @@ -3,7 +3,7 @@ module github.com/goceleris/probatorium/servers/celeris go 1.26.4 require ( - github.com/goceleris/celeris v1.5.3 + github.com/goceleris/celeris v1.5.4 github.com/goceleris/probatorium v0.0.0-00010101000000-000000000000 ) diff --git a/servers/celeris/go.sum b/servers/celeris/go.sum index f317e88..aa010a5 100644 --- a/servers/celeris/go.sum +++ b/servers/celeris/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/validation/refapp/auth_jwt_csrf/go.mod b/validation/refapp/auth_jwt_csrf/go.mod index 4a0f1aa..6ec91da 100644 --- a/validation/refapp/auth_jwt_csrf/go.mod +++ b/validation/refapp/auth_jwt_csrf/go.mod @@ -2,7 +2,7 @@ module github.com/goceleris/probatorium/validation/refapp/auth_jwt_csrf go 1.26.4 -require github.com/goceleris/celeris v1.5.3 +require github.com/goceleris/celeris v1.5.4 require ( golang.org/x/net v0.56.0 // indirect diff --git a/validation/refapp/auth_jwt_csrf/go.sum b/validation/refapp/auth_jwt_csrf/go.sum index f317e88..aa010a5 100644 --- a/validation/refapp/auth_jwt_csrf/go.sum +++ b/validation/refapp/auth_jwt_csrf/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/validation/refapp/auth_session_ratelimit/go.mod b/validation/refapp/auth_session_ratelimit/go.mod index 49a7ceb..f6bfb8c 100644 --- a/validation/refapp/auth_session_ratelimit/go.mod +++ b/validation/refapp/auth_session_ratelimit/go.mod @@ -2,7 +2,7 @@ module github.com/goceleris/probatorium/validation/refapp/auth_session_ratelimit go 1.26.4 -require github.com/goceleris/celeris v1.5.3 +require github.com/goceleris/celeris v1.5.4 require ( golang.org/x/net v0.56.0 // indirect diff --git a/validation/refapp/auth_session_ratelimit/go.sum b/validation/refapp/auth_session_ratelimit/go.sum index f317e88..aa010a5 100644 --- a/validation/refapp/auth_session_ratelimit/go.sum +++ b/validation/refapp/auth_session_ratelimit/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/validation/refapp/driver_memcached/go.mod b/validation/refapp/driver_memcached/go.mod index 856086e..7e3108f 100644 --- a/validation/refapp/driver_memcached/go.mod +++ b/validation/refapp/driver_memcached/go.mod @@ -2,7 +2,7 @@ module github.com/goceleris/probatorium/validation/refapp/driver_memcached go 1.26.4 -require github.com/goceleris/celeris v1.5.3 +require github.com/goceleris/celeris v1.5.4 require ( golang.org/x/net v0.56.0 // indirect diff --git a/validation/refapp/driver_memcached/go.sum b/validation/refapp/driver_memcached/go.sum index f317e88..aa010a5 100644 --- a/validation/refapp/driver_memcached/go.sum +++ b/validation/refapp/driver_memcached/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/validation/refapp/driver_postgres/go.mod b/validation/refapp/driver_postgres/go.mod index 58f26e8..54b36fd 100644 --- a/validation/refapp/driver_postgres/go.mod +++ b/validation/refapp/driver_postgres/go.mod @@ -2,7 +2,7 @@ module github.com/goceleris/probatorium/validation/refapp/driver_postgres go 1.26.4 -require github.com/goceleris/celeris v1.5.3 +require github.com/goceleris/celeris v1.5.4 require ( golang.org/x/net v0.56.0 // indirect diff --git a/validation/refapp/driver_postgres/go.sum b/validation/refapp/driver_postgres/go.sum index f317e88..aa010a5 100644 --- a/validation/refapp/driver_postgres/go.sum +++ b/validation/refapp/driver_postgres/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/validation/refapp/driver_redis/go.mod b/validation/refapp/driver_redis/go.mod index 8ef02bc..8179524 100644 --- a/validation/refapp/driver_redis/go.mod +++ b/validation/refapp/driver_redis/go.mod @@ -2,7 +2,7 @@ module github.com/goceleris/probatorium/validation/refapp/driver_redis go 1.26.4 -require github.com/goceleris/celeris v1.5.3 +require github.com/goceleris/celeris v1.5.4 require ( golang.org/x/net v0.56.0 // indirect diff --git a/validation/refapp/driver_redis/go.sum b/validation/refapp/driver_redis/go.sum index f317e88..aa010a5 100644 --- a/validation/refapp/driver_redis/go.sum +++ b/validation/refapp/driver_redis/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/validation/refapp/kitchen_sink/go.mod b/validation/refapp/kitchen_sink/go.mod index 48ca237..093006f 100644 --- a/validation/refapp/kitchen_sink/go.mod +++ b/validation/refapp/kitchen_sink/go.mod @@ -2,7 +2,7 @@ module github.com/goceleris/probatorium/validation/refapp/kitchen_sink go 1.26.4 -require github.com/goceleris/celeris v1.5.3 +require github.com/goceleris/celeris v1.5.4 require ( golang.org/x/net v0.56.0 // indirect diff --git a/validation/refapp/kitchen_sink/go.sum b/validation/refapp/kitchen_sink/go.sum index f317e88..aa010a5 100644 --- a/validation/refapp/kitchen_sink/go.sum +++ b/validation/refapp/kitchen_sink/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/validation/refapp/observability/go.mod b/validation/refapp/observability/go.mod index 0b97bb7..bbc270e 100644 --- a/validation/refapp/observability/go.mod +++ b/validation/refapp/observability/go.mod @@ -3,9 +3,9 @@ module github.com/goceleris/probatorium/validation/refapp/observability go 1.26.4 require ( - github.com/goceleris/celeris v1.5.3 - github.com/goceleris/celeris/middleware/metrics v1.5.3 - github.com/goceleris/celeris/middleware/otel v1.5.3 + github.com/goceleris/celeris v1.5.4 + github.com/goceleris/celeris/middleware/metrics v1.5.4 + github.com/goceleris/celeris/middleware/otel v1.5.4 github.com/prometheus/client_golang v1.23.2 ) diff --git a/validation/refapp/observability/go.sum b/validation/refapp/observability/go.sum index 3341481..607c46f 100644 --- a/validation/refapp/observability/go.sum +++ b/validation/refapp/observability/go.sum @@ -9,12 +9,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= -github.com/goceleris/celeris/middleware/metrics v1.5.3 h1:mCt0xzU29+NnwT71rW9PmUAwAEClg9+yUaqgggdqqeE= -github.com/goceleris/celeris/middleware/metrics v1.5.3/go.mod h1:CoMqyeSBZNAmKeaGGrFuBWsoKmiir8huzRj1Dcz5V1g= -github.com/goceleris/celeris/middleware/otel v1.5.3 h1:/uN0whKfMhM4S6ERrK4MM2zzZEZXmZZY5sQjoCSlPgg= -github.com/goceleris/celeris/middleware/otel v1.5.3/go.mod h1:4mUnMXf61RiwtpZcv1by2Ul8fjXNnJ66gNml3GulNec= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris/middleware/metrics v1.5.4 h1:+eSbJ6h015B02musx3iFeHOC5E0Ka5hVJ0NwTLHixVw= +github.com/goceleris/celeris/middleware/metrics v1.5.4/go.mod h1:7M52pYFnuTvp47i7lshTu/pQmf54aZAyGK774FGagsk= +github.com/goceleris/celeris/middleware/otel v1.5.4 h1:Cm8NQeJyfKg91VoXCXl5/I0QjqWwL9hlkhSyIY1n5jk= +github.com/goceleris/celeris/middleware/otel v1.5.4/go.mod h1:8GQYwkGRm5IkdKW8lSr+++CGCk7R7P4HiHiz5PVT1z0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/validation/refapp/static_swagger_proxy/go.mod b/validation/refapp/static_swagger_proxy/go.mod index 5b02a9b..07d950b 100644 --- a/validation/refapp/static_swagger_proxy/go.mod +++ b/validation/refapp/static_swagger_proxy/go.mod @@ -2,7 +2,7 @@ module github.com/goceleris/probatorium/validation/refapp/static_swagger_proxy go 1.26.4 -require github.com/goceleris/celeris v1.5.3 +require github.com/goceleris/celeris v1.5.4 require ( golang.org/x/net v0.56.0 // indirect diff --git a/validation/refapp/static_swagger_proxy/go.sum b/validation/refapp/static_swagger_proxy/go.sum index f317e88..aa010a5 100644 --- a/validation/refapp/static_swagger_proxy/go.sum +++ b/validation/refapp/static_swagger_proxy/go.sum @@ -1,5 +1,5 @@ -github.com/goceleris/celeris v1.5.3 h1:n3/2xAqwqyMG0c9g1bP+F2KdjZngCOgGFHup1f3fRY4= -github.com/goceleris/celeris v1.5.3/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= +github.com/goceleris/celeris v1.5.4 h1:mPHo5CsZx+J8has9yXK3vvmmgEV6eOiiVYC8DHDk08U= +github.com/goceleris/celeris v1.5.4/go.mod h1:usrqjcfMxiNA4lFoE/YbEzX10+UFhoJ4JQVkAS7noSc= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= From 4a75f15134d1b6afbd83eeed215be2fdb16a20c5 Mon Sep 17 00:00:00 2001 From: Albert Bausili Date: Tue, 23 Jun 2026 12:52:17 +0200 Subject: [PATCH 11/11] lint(scenarios): drop unused chainScenarioName (dead since chain removal) --- scenarios/chain.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/scenarios/chain.go b/scenarios/chain.go index 1d3bace..c47f41e 100644 --- a/scenarios/chain.go +++ b/scenarios/chain.go @@ -129,12 +129,6 @@ func (s *ChainScenario) Applicable(fs servers.FeatureSet) bool { // Compile-time assertion that ChainScenario satisfies Scenario. var _ Scenario = (*ChainScenario)(nil) -// chainScenarioName returns the canonical name for a (chain, workload) -// pair, e.g. ("api", "get-json-1c") → "chain-api-get-json-1c". -func chainScenarioName(chain, workload string) string { - return "chain-" + chain + "-" + workload -} - // Chain scenarios are intentionally NOT registered: they compare unequal // work across adapters (each framework's middleware stack differs), so the // numbers aren't comparable and aren't worth the compute. The types above