From 20c3a88070d5b7af58471c94c916ccc6692b67fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9?= Date: Wed, 2 Sep 2026 17:18:42 +0200 Subject: [PATCH 1/2] fiber, fiber-tuned: prefork, a correctness pass, and a tuned sibling fiber (standard) ---------------- - meta.json carried an empty maintainers array, so nobody was pinged on a PR touching the entry. It now names the gofiber/maintainers team from the Fiber repo's CODEOWNERS. - EnablePrefork is on: one Go runtime per logical CPU behind one SO_REUSEPORT socket per worker. The master binds nothing and loads neither dataset nor Postgres pool nor Redis client, and the pool is DATABASE_MAX_CONN divided by the worker count, since that budget belongs to the container rather than to a process. - Static files pick up the pre-compressed .br/.gz sibling the harness ships beside each file, chosen with Fiber's AcceptsEncodings. The static profiles send "br;q=1, gzip;q=0.8" and fasthttp's matcher compares whole tokens, so the entry was answering the whole 20-file rotation uncompressed: 1.21 MB per rotation becomes about 318 KB. - GET /delay/{ms} implemented, async subscribed. Fixes: - TLS took Fiber's CertFile/CertKeyFile path, which installs TLSHandler.GetClientInfo as GetCertificate and writes clientHelloInfo on a shared struct with no synchronisation on every handshake (fiber/v3@v3.5.0 ctx.go:95-98). Three profiles drive :8081 with overlapping handshakes; reproduced under -race with 120 concurrent ones. ListenConfig.TLSConfig takes the branch that installs no handler, with the same TLS posture. This affects every Fiber application on that path, not just this entry. - A listener bind failure could hang the worker instead of exiting. - SIGTERM did not drain: Listen returns straight away, so main exited and took the in-flight response with it. - /delay/{ms} overflowed int64 past roughly 1e14 ms and answered immediately, the one answer that profile forbids. Over an hour is 404. - queryItems swallowed a mid-iteration row error behind a short 200. - crudCreate is an upsert but never invalidated its Redis key. - loadDataset, loadPgPool and a malformed REDIS_URL failed silently. Rules: - The async-db pool was clamped to NumCPU()*4 on top of the budget, which that profile's standard rule forbids. The clamp is gone. - Hand-rolled c.Query + strconv.Atoi helpers give way to fiber.Query[int] and fiber.Params[int]. The leaderboard description was 494 characters against a board median of 136; it is 284 now and names the pre-compressed static path. Every remaining claim in README.md, meta.json and the comments was checked against the code and corrected where wrong. fiber-tuned (tuned, new) ------------------------ The fiber entry with the four things it leaves at Fiber's defaults: - sonic behind Config.JSONEncoder/JSONDecoder, pretouched at startup. - compress middleware at LevelBestSpeed (brotli 0, gzip 1). Only json-comp is affected; the static twins are pre-compressed. - fasthttp pinned to master c96f600 for valyala/fasthttp#2366, which replaced the unmaintained andybalholm/brotli with molecule-man/go-brrr. Fiber 3.5.0 builds against it unchanged. - Postgres pool filled at startup (MinConns = MaxConns). The size is unchanged, so what is tuned is when the connections open, not how many. Routes, prefork, static and TLS paths are fiber's, unchanged. Measured in a 4-vCPU sandbox (one worker per build pinned to a core, the generator on two others, CPU per request from /proc, two rounds alternating), json-comp goes from 367-378 us to 97-98 us; the control endpoint spreads 11-16 us across identical code, so nothing smaller is called a difference. Both READMEs carry the isolated benchmarks and a level-by-level go-brrr table. docs ---- site/content/docs/test-profiles/h1/static-tls/implementation.md has said the rotation is "~842 KB across 20 files" since before the fixtures grew. Measured over data/static it is 1,271,603 B, of which 1,170,227 B is compressible text. Validated with the real suite on this tree, Postgres sidecar and TLS: fiber 73 passed 0 failed, fiber-tuned 73 passed 0 failed. JSON bodies are byte-identical between the two builds. --- frameworks/fiber-tuned/Dockerfile | 11 + frameworks/fiber-tuned/README.md | 194 +++++ frameworks/fiber-tuned/go.mod | 40 + frameworks/fiber-tuned/go.sum | 97 +++ frameworks/fiber-tuned/main.go | 714 ++++++++++++++++++ frameworks/fiber-tuned/meta.json | 36 + frameworks/fiber/Dockerfile | 2 +- frameworks/fiber/README.md | 166 +++- frameworks/fiber/main.go | 411 ++++++++-- frameworks/fiber/meta.json | 11 +- .../h1/static-tls/implementation.md | 2 +- site/data/frameworks.json | 16 +- 12 files changed, 1600 insertions(+), 100 deletions(-) create mode 100644 frameworks/fiber-tuned/Dockerfile create mode 100644 frameworks/fiber-tuned/README.md create mode 100644 frameworks/fiber-tuned/go.mod create mode 100644 frameworks/fiber-tuned/go.sum create mode 100644 frameworks/fiber-tuned/main.go create mode 100644 frameworks/fiber-tuned/meta.json diff --git a/frameworks/fiber-tuned/Dockerfile b/frameworks/fiber-tuned/Dockerfile new file mode 100644 index 000000000..c7a7ee24c --- /dev/null +++ b/frameworks/fiber-tuned/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +RUN CGO_ENABLED=0 go build -o server main.go + +FROM alpine:3.23 +COPY --from=build /app/server /server +EXPOSE 8080 8081 +CMD ["/server"] diff --git a/frameworks/fiber-tuned/README.md b/frameworks/fiber-tuned/README.md new file mode 100644 index 000000000..8bf9a4f6c --- /dev/null +++ b/frameworks/fiber-tuned/README.md @@ -0,0 +1,194 @@ +# fiber-tuned + +The [`fiber`](../fiber/) entry with four things the standard entry leaves at +the framework's defaults: sonic for JSON, the compress middleware at its +best-speed level, a Postgres pool filled at startup, and fasthttp at `master` +rather than at the release Fiber 3.5.0 pins — for the brotli library master +swapped in. sonic is what tuned mode exists for and standard mode forbids. The +level and the pool fill are documented options of the middleware and the driver +the standard entry already uses — `carter` and `salvo` run their compression at +level 1 in standard mode — and they sit here rather than in `fiber` because +that entry's line is *default configuration*, and a level is a configuration. +So is a dependency pin. + +The server is otherwise the same: the routes, prefork, the per-request static +reads, the `.br`/`.gz` twin selection, the TLS listener on `8081`, and the +drain-on-signal shutdown are all `fiber`'s, unchanged. `fiber`'s README +documents them, and this file covers only what differs. + +Both entries appear on the board — `fiber` as `mode: standard`, this one as +`mode: tuned` with the ring the board draws for tuned entries. `/benchmark` +runs them side by side, so each tuned change shows up as a delta against the +standard entry rather than as a claim. + +## What is tuned + +| | `fiber` (standard) | `fiber-tuned` | +|---|---|---| +| JSON | `encoding/json` | `sonic` behind `c.JSON` | +| Compress level | framework default | best speed | +| Postgres pool | opened lazily | opened to full size at startup | +| fasthttp | v1.73.0, Fiber 3.5.0's pin | `master` at `c96f600` (2026-08-31): brotli by go-brrr | + +Everything else — the body limit, the buffer sizes, `GOGC`, the worker count — +is left at the framework's and the runtime's defaults, the same as the standard +entry. Tuned mode would allow more; these are the changes with a clear rationale +and a measurable effect, and the point of the pair is to measure them rather +than to collect knobs. + +## Measured + +Not board numbers — those come from `/benchmark` on the reference hardware. +These are from a 4-vCPU sandbox: one prefork worker of each build (child mode, +`GOMAXPROCS(1)`) pinned to one core, the load generator on two others, 16 +keep-alive connections, two rounds in alternating order. The metric is CPU per +request, read from `/proc`, because it does not depend on whether the generator +saturates the server. Three builds: `fiber`; this entry on the fasthttp Fiber +pins (v1.73.0); this entry as shipped, on fasthttp `master`. + +| request | `fiber` | tuned, fasthttp v1.73.0 | tuned, fasthttp master | +|---|---|---|---| +| `/json/50?m=6`, `Accept-Encoding: gzip, br` (json-comp) | 367–378 µs, 1490 B | 148–149 µs, 1940 B | **97–98 µs**, 1944 B | +| `/json/50?m=6`, no encoding (json-tls, minus TLS) | 85 µs | 60–63 µs | 65–70 µs | +| `/baseline11?a=13&b=42` (control) | 11.2–12.9 µs | 14.5–14.9 µs | 11.9–16.5 µs | + +The control row is the noise floor: identical code in all three columns, and +it spreads over 11–16 µs. Read the other rows against that. json-comp moves by +hundreds of microseconds and is real; the 5 µs between the two tuned builds on +the uncompressed row is inside the control's own spread, and this README does +not call it a difference. + +The deltas in isolation (`go test -bench`, the exact 8397-byte body, fasthttp's +own pooled writers, so the same code path the middleware takes): + +| work | `fiber` (fasthttp v1.73.0) | tuned (fasthttp master) | +|---|---|---| +| JSON marshal: `encoding/json` → sonic | 35.1 µs | 12.2 µs | +| brotli level 4 (`LevelDefault`): andybalholm → go-brrr | 259 µs, 1490 B | 107 µs, 1489 B | +| brotli level 0 (`LevelBestSpeed`): andybalholm → go-brrr | 68 µs, 1940 B | 25.6 µs, 1944 B | +| gzip level 6 → 1 (klauspost, the same on master) | 62 µs, 1519 B | 33 µs, 1722 B | + +And go-brrr at every level this entry could have picked, same body: + +| level | µs | bytes | +|---|---|---| +| 0 (`LevelBestSpeed`) | 25.6 | 1944 | +| 1 | 32.5 | 1876 | +| 2 | 51 | 1553 | +| 4 (`LevelDefault`) | 107 | 1489 | +| 6 | 167 | 1367 | + +Level 0 stays the choice. json-comp scores requests per second and requires +valid brotli, not a ratio; at these sizes the 450 extra bytes are nothing on +the wire and the 80 µs between level 0 and the default are most of a request. +Level 2 is the interesting middle — the default's byte count at half its cost — +and the table is here so that whoever wants that trade can see it. + +What the tables also show about the standard entry: brotli at fasthttp's +default level 4, on the library the release ships, is about two thirds of its +json-comp cost, and four times what gzip at its own default takes for a body +two per cent larger. That is the default, and the standard entry ships the +default. + +### JSON: sonic + +`fiber.Config{JSONEncoder: sonic.Marshal, JSONDecoder: sonic.Unmarshal}`, and +the handlers that (de)serialize directly call `sonic` too. `c.JSON` runs the +configured encoder (`res.go` calls `app.config.JSONEncoder`), so this reaches +`/json`, `/async-db` and the crud responses without touching a handler. + +- Tuned mode names this first: "Alternative JSON serializers (simd-json, + sonic-json, etc.)", and the `json-comp` and `json-tls` tuned rules both lead + with "alternative JSON libraries". It is the one change standard mode most + squarely forbids and tuned mode most squarely invites. +- It is the JIT build, not the `encoding/json` fallback. sonic's build tags + select the assembly path for `amd64` and `arm64` on every Go from 1.17 up to + (not including) 1.28; the 1.26 toolchain in the Dockerfile is inside that + range. On another architecture sonic compiles to a wrapper over + `encoding/json` and this entry would simply match the standard one. +- The bytes are the same. For the dataset's types sonic's output is identical + to `encoding/json` (checked), and the one default that differs — sonic does + not HTML-escape `<`, `>`, `&` — has nothing to act on: the dataset contains + none of those three characters in any string field. +- sonic compiles an encoder per type on first use. `pretouchJSON` does that at + startup, once per worker, so the JIT compile lands where nothing is being + measured instead of in the first requests of a run. + +### Compression: best speed + +`compress.New(compress.Config{Level: compress.LevelBestSpeed})` on the same two +prefixes the standard entry mounts it on (`/json`, `/static`). + +- Tuned mode allows "tuned compression libraries" and "Any compression approach + for static files"; `json-comp` tuned allows "tuned compression libraries … as + long as the output is valid gzip or brotli". +- The profile it changes is `json-comp`, which scores requests per second for a + body serialized and compressed per request and requires only *valid* gzip or + brotli, not a ratio. Fiber's middleware picks brotli for the profile's + `Accept-Encoding: gzip, br`. `LevelDefault` maps to brotli level 4 and gzip + level 6 (fasthttp's `CompressBrotliDefaultCompression` and + `CompressDefaultCompression`); `LevelBestSpeed` maps to brotli level 0 and + gzip level 1. Measured above: 259 µs → 68 µs per body for brotli, at 1490 B → + 1940 B on the wire. +- The level is an option of the same middleware the standard entry mounts, not + a different library, and `carter` and `salvo` set theirs to level 1 in + standard mode. It could live in `fiber`; whether it should is a question + about what that entry is for, and this pair keeps the answer measurable. +- Static is unaffected: the twins on disk are already compressed, and the + middleware leaves an encoded body alone — so the level only ever applies to + the per-request `/json` path. + +### fasthttp: master, for go-brrr + +`go.mod` raises fasthttp from the v1.73.0 that Fiber 3.5.0 pins to `master` at +`c96f600` (2026-08-31). Fiber's own requirement stays as it is; Go's minimum +version selection takes the higher of the two, and Fiber 3.5.0 builds against +it unchanged. + +- What master has that v1.73.0 does not is + [valyala/fasthttp#2366](https://github.com/valyala/fasthttp/pull/2366), merged + 2026-08-29: `andybalholm/brotli` replaced by `molecule-man/go-brrr`, a pure-Go + brotli. The PR's motivation is that the old library is no longer maintained + and its author points at go-brrr; the PR's own numbers on a 256 KiB HTML are + 2.0× at level 0 and 1.16× at level 4, and on this entry's 8 KB JSON above + they are 2.7× and 2.4×. +- Wire-compatible. fasthttp's level constants keep their values, and the PR + checked encode and decode both ways against streams from the old library. + Its reviewer found the one behavioural difference — go-brrr accepted trailing + bytes the old decoder rejected — and the merged PR closes it. Decoding is not + on this entry's path anyway; it only encodes. +- Tuned mode allows "tuned compression libraries"; this is one, taken through + the framework's own dependency rather than around it. The compress middleware + is unchanged and does not know which library fasthttp built it on. +- The standard entry stays on the release, deliberately: v1.73.0 is what Fiber + 3.5.0 pins, and "default configuration" includes the dependency graph. When + fasthttp cuts the release that carries #2366 and Fiber picks it up, `fiber` + gets it for free and this row of the table closes to zero — which is the + kind of thing a tuned/standard pair is for. + +### Postgres pool: eager fill + +`cfg.MinConns = cfg.MaxConns`, alongside the `MaxConns` the standard entry +already sets from `DATABASE_MAX_CONN`. + +- Tuned mode allows "custom pool sizes … or driver-specific optimizations + beyond defaults". The *size* is unchanged from the standard entry — it is the + server's budget divided by the worker count, and a pool bigger than Postgres + accepts only fails to fill — so what is tuned is *when* the connections open. +- pgxpool opens connections lazily by default, so in the standard entry the + first `async-db` requests of a run each pay for a TCP connect and a Postgres + handshake. `MinConns` set to the pool size makes pgxpool open them at startup, + in a background goroutine, before load arrives. +- Whether that reaches the board is doubtful, and this README should say so: + each profile runs three times and the best run is kept, which discards a cold + start by design. The fill matters to the first requests after a container + starts — a real thing in production, where a deploy is a cold start under + live traffic — not to a steady-state score. The same goes for `pretouchJSON`. + +## Build + +Identical to `fiber` (`golang:1.26-alpine` → `alpine:3.23`, `CGO_ENABLED=0`). +sonic needs no cgo and neither does go-brrr. What `go.mod` has that `fiber`'s +does not: `github.com/bytedance/sonic`, fasthttp raised to the `master` +pseudo-version, and `github.com/molecule-man/go-brrr` arriving through it in +place of `github.com/andybalholm/brotli`. diff --git a/frameworks/fiber-tuned/go.mod b/frameworks/fiber-tuned/go.mod new file mode 100644 index 000000000..a64753fcf --- /dev/null +++ b/frameworks/fiber-tuned/go.mod @@ -0,0 +1,40 @@ +module httparena/fiber-tuned + +go 1.25.0 + +require ( + github.com/bytedance/sonic v1.15.3 + github.com/gofiber/fiber/v3 v3.5.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/redis/go-redis/v9 v9.22.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic/loader v0.5.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gofiber/schema v1.8.3 // indirect + github.com/gofiber/utils/v2 v2.4.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/molecule-man/go-brrr v1.0.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.73.1-0.20260831064256-c96f600972c6 // indirect; master, for go-brrr brotli (valyala/fasthttp#2366) + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect +) diff --git a/frameworks/fiber-tuned/go.sum b/frameworks/fiber-tuned/go.sum new file mode 100644 index 000000000..26df5b67e --- /dev/null +++ b/frameworks/fiber-tuned/go.sum @@ -0,0 +1,97 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY= +github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8= +github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w= +github.com/gofiber/schema v1.8.3 h1:06ZedxIYjngzc0095PYy7uWnFnbRflWFpikvZH61fDc= +github.com/gofiber/schema v1.8.3/go.mod h1:jWnnZdhcW1mHyV+VnfRxKJDPNcepJsTZ9RIWxrr32Ng= +github.com/gofiber/utils/v2 v2.4.1 h1:E2X9G8O5Mn7b2GDb0JU3IUk42Rw2npuhhepIbuJQ2po= +github.com/gofiber/utils/v2 v2.4.1/go.mod h1:I+RTsgMUdzFuifVc3LOEkfh32wQW9BfRl7l5RYjamW4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/molecule-man/go-brrr v1.0.1 h1:cEjgx8hgNw6UGdhQ94SPDbPkKuRbkUcxBO3IzbGpA/o= +github.com/molecule-man/go-brrr v1.0.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8= +github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.73.1-0.20260831064256-c96f600972c6 h1:a3tkE/8Is/DO2/N/jexqoNuMTp25M8e7RksIZCKjmr0= +github.com/valyala/fasthttp v1.73.1-0.20260831064256-c96f600972c6/go.mod h1:KqkgZrjpWx1jdy99/J9SlOVjSp9H+uWJaVAHTNGAgeU= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/frameworks/fiber-tuned/main.go b/frameworks/fiber-tuned/main.go new file mode 100644 index 000000000..1cba2c8de --- /dev/null +++ b/frameworks/fiber-tuned/main.go @@ -0,0 +1,714 @@ +// fiber-tuned is the fiber entry with what the standard entry leaves at the +// framework's defaults: sonic in place of encoding/json, the compress +// middleware at its best-speed level, the Postgres pool opened to its full +// size at startup rather than lazily, and - in go.mod, not here - fasthttp at +// master instead of the v1.73.0 Fiber 3.5.0 pins, for the go-brrr brotli that +// valyala/fasthttp#2366 swapped in. Everything else - the routes, prefork, the +// static and TLS paths, the shutdown - is the standard entry's, unchanged, and +// its README explains those. +package main + +import ( + "context" + "crypto/tls" + "log" + "os" + "os/signal" + "path/filepath" + "reflect" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/bytedance/sonic" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/compress" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +// How long a worker keeps serving after it is signalled, before it stops +// waiting for the requests still in flight. +const shutdownGrace = 3 * time.Second + +// Closed once the shutdown started by a signal has finished, so main can wait +// for it. Nil when this process is not serving. +var drained chan struct{} + +// workerProcesses is how many processes end up sharing anything the container +// holds once - the connection budget below, most of all. +// +// Prefork forks GOMAXPROCS children, the value read in the master before it +// spawns anything. A child re-runs main() from the top and reads the same +// number here, because the GOMAXPROCS(1) that prefork applies to a child +// happens later, when it takes its listener. +func workerProcesses() int { + return runtime.GOMAXPROCS(0) +} + +type Rating struct { + Score int `json:"score"` + Count int `json:"count"` +} + +type DatasetItem struct { + ID int `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Price int `json:"price"` + Quantity int `json:"quantity"` + Active bool `json:"active"` + Tags []string `json:"tags"` + Rating Rating `json:"rating"` +} + +type ProcessedItem struct { + DatasetItem + Total int `json:"total"` +} + +type ProcessResponse struct { + Items []ProcessedItem `json:"items"` + Count int `json:"count"` +} + +var dataset []DatasetItem + +func loadDataset() { + path := os.Getenv("DATASET_PATH") + if path == "" { + path = "/data/dataset.json" + } + // Logged rather than swallowed: with no dataset every /json/{count} answers + // 200 with an empty list, which looks like a working server right up until + // the numbers are compared against the file. + data, err := os.ReadFile(path) + if err != nil { + log.Printf("dataset %s: %v", path, err) + return + } + if err := sonic.Unmarshal(data, &dataset); err != nil { + log.Printf("dataset %s: %v", path, err) + } +} + +// sonic compiles an encoder per type the first time it meets it. Doing that +// here, once per worker at startup, keeps the compile out of the first +// requests of a run - the JIT is the point of choosing it, so pay for it +// where nothing is being measured. +func pretouchJSON() { + for _, t := range []reflect.Type{ + reflect.TypeOf(ProcessResponse{}), + reflect.TypeOf(itemsResponse{}), + reflect.TypeOf(fiber.Map{}), + reflect.TypeOf(crudBody{}), + } { + if err := sonic.Pretouch(t); err != nil { + log.Printf("sonic pretouch %s: %v", t, err) + } + } +} + +func pipeline(c fiber.Ctx) error { + return c.SendString("ok") +} + +// The profile sends a and b and nothing else, so they are read by name through +// Fiber's typed query binder rather than materialising the whole query string +// into a map. Both are hot: baseline drives this endpoint at 4096 connections +// and latency-1m and latency-10k score what a request costs in CPU, where one +// map allocation per request is a line item. +func baseline11(c fiber.Ctx) error { + sum := fiber.Query[int](c, "a") + fiber.Query[int](c, "b") + if c.Method() == fiber.MethodPost { + if n, err := strconv.Atoi(strings.TrimSpace(string(c.Body()))); err == nil { + sum += n + } + } + return c.SendString(strconv.Itoa(sum)) +} + +// The longest wait this will serve. The profile asks for 10ms and validation +// for at most half a second; the cap is here because time.Duration(ms) * +// time.Millisecond overflows int64 past about 292 years' worth of milliseconds, +// and an overflowed duration is negative, so the handler answers immediately - +// the one answer this endpoint is not allowed to give. +const maxDelayMillis = int(time.Hour / time.Millisecond) + +// GET /delay/{ms}: answer no earlier than the wait named in the path. A +// goroutine parked on a timer is what Fiber gives you for free here - the +// handler blocks, the process does not. +func delay(c fiber.Ctx) error { + ms := fiber.Params[int](c, "ms", -1) + if ms < 0 || ms > maxDelayMillis { + return c.SendStatus(fiber.StatusNotFound) + } + if ms > 0 { + time.Sleep(time.Duration(ms) * time.Millisecond) + } + return c.SendString(strconv.Itoa(ms)) +} + +func jsonItems(c fiber.Ctx) error { + count := fiber.Params[int](c, "count", 0) + if count < 0 { + count = 0 + } + if count > len(dataset) { + count = len(dataset) + } + // An explicit m=0 reads as "not given", the same as an absent or unparsable + // one. Every entry in the repo does this and the profiles only ever send + // m >= 1, so the alternative is a column of zero totals that nothing asks + // for and that no other row would report. + m := fiber.Query[int](c, "m", 1) + if m == 0 { + m = 1 + } + + items := make([]ProcessedItem, count) + for i := 0; i < count; i++ { + d := dataset[i] + items[i] = ProcessedItem{DatasetItem: d, Total: d.Price * d.Quantity * m} + } + return c.JSON(ProcessResponse{Items: items, Count: count}) +} + +func echoBody(c fiber.Ctx) error { + // fasthttp has already read the body, chunked or not, so the echo is the + // buffer it holds -- Send sets Content-Length from it. + c.Set(fiber.HeaderContentType, "application/octet-stream") + return c.Send(c.Body()) +} + +var pgPool *pgxpool.Pool +var rdb *redis.Client + +const itemColumns = "id, name, category, price, quantity, active, tags, rating_score, rating_count" + +// The crud routes read and write the same ids, so a long TTL would answer from +// a copy the writes have already moved past. No profile drives them any more; +// the TTL is kept at what the workload they were built for needed. +const crudTTL = 200 * time.Millisecond + +// The pool is sized from DATABASE_MAX_CONN, as in the standard entry. Tuned +// mode allows "custom pool sizes", but the number is the server's, not this +// entry's: a pool larger than what Postgres accepts is a pool that fails to +// fill, and a smaller one gives connections away. +// +// The 8 subtracted below is a safety margin rather than an exact figure: the +// server keeps 3 connections back for the superuser by default, and the +// harness's own psql and pg_isready probes want one now and then. The remainder +// is divided by the worker count because the budget belongs to the container, +// not to a process, and every child opens a pool of its own against the same +// server - undivided, the fleet would ask for sixty-four times what it can get. +// +// What is tuned is when the connections are opened. pgxpool opens them lazily, +// so in the standard entry the first requests of a run pay for a TCP connect +// and a Postgres handshake each. MinConns set to the pool size has the pool +// open them at startup instead, in a goroutine, before load arrives. +func loadPgPool() { + url := os.Getenv("DATABASE_URL") + if url == "" { + return + } + cfg, err := pgxpool.ParseConfig(url) + if err != nil { + log.Printf("database url: %v", err) + return + } + budget := 256 + if v := os.Getenv("DATABASE_MAX_CONN"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + budget = n + } + } + workers := workerProcesses() + maxConns := (budget - 8) / workers + if maxConns < 1 { + maxConns = 1 + } + cfg.MaxConns = int32(maxConns) + cfg.MinConns = int32(maxConns) + pool, err := pgxpool.NewWithConfig(context.Background(), cfg) + if err != nil { + log.Printf("database pool: %v", err) + return + } + pgPool = pool +} + +func loadRedis() { + url := os.Getenv("REDIS_URL") + if url == "" { + return + } + opt, err := redis.ParseURL(url) + if err != nil { + log.Printf("redis url: %v", err) + return + } + rdb = redis.NewClient(opt) +} + +// tags is a JSONB column, so it comes back as bytes rather than a Go slice. +func queryItems(ctx context.Context, sql string, args ...any) ([]DatasetItem, error) { + rows, err := pgPool.Query(ctx, sql, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DatasetItem{} + for rows.Next() { + var it DatasetItem + var tags []byte + if err := rows.Scan(&it.ID, &it.Name, &it.Category, &it.Price, &it.Quantity, + &it.Active, &tags, &it.Rating.Score, &it.Rating.Count); err != nil { + return nil, err + } + if len(tags) > 0 { + if err := sonic.Unmarshal(tags, &it.Tags); err != nil { + return nil, err + } + } + if it.Tags == nil { + it.Tags = []string{} + } + items = append(items, it) + } + // A connection that fails mid-iteration ends the loop like a clean finish + // does. Without this the handler would answer 200 with however many rows + // arrived before the error, which reads as a short result rather than a + // failed one. + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// The async-db response is a struct rather than a fiber.Map: same JSON, without +// asking encoding/json to reflect over a map and sort its keys per request. +type itemsResponse struct { + Items []DatasetItem `json:"items"` + Count int `json:"count"` +} + +var emptyItems = itemsResponse{Items: []DatasetItem{}} + +// Every database and cache call is answered inside this deadline. +// +// Fiber's Ctx.Context() is a background context unless the application puts one +// there, and fasthttp has no per-request cancellation to put there either - a +// client that walks away mid-query leaves the query running and its pool +// connection held. A deadline is what bounds that, and it is what the net/http +// entries get from the request context for free. +const dbTimeout = 5 * time.Second + +func dbContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), dbTimeout) +} + +func asyncDb(c fiber.Ctx) error { + if pgPool == nil { + return c.JSON(emptyItems) + } + ctx, cancel := dbContext() + defer cancel() + items, err := queryItems(ctx, + "SELECT "+itemColumns+" FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3", + fiber.Query[int](c, "min", 10), fiber.Query[int](c, "max", 50), + clamp(fiber.Query[int](c, "limit", 50), 1, 50)) + // An empty list rather than a 500: it is the shape the profile documents and + // what every other entry answers here. It does mean a database that is down + // reads the same as a price range with nothing in it - validation tells them + // apart, because it asserts count == limit on ranges that do have rows. + if err != nil { + return c.JSON(emptyItems) + } + return c.JSON(itemsResponse{Items: items, Count: len(items)}) +} + +func crudList(c fiber.Ctx) error { + if pgPool == nil { + return c.Status(500).JSON(fiber.Map{"error": "DB not available"}) + } + category := c.Query("category") + if category == "" { + category = "electronics" + } + page := fiber.Query[int](c, "page", 1) + if page < 1 { + page = 1 + } + limit := clamp(fiber.Query[int](c, "limit", 10), 1, 50) + ctx, cancel := dbContext() + defer cancel() + items, err := queryItems(ctx, + "SELECT "+itemColumns+" FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3", + category, limit, (page-1)*limit) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "query failed"}) + } + return c.JSON(fiber.Map{"items": items, "total": len(items), "page": page, "limit": limit}) +} + +type crudBody struct { + ID int `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Price int `json:"price"` + Quantity int `json:"quantity"` +} + +func crudCreate(c fiber.Ctx) error { + if pgPool == nil { + return c.Status(500).JSON(fiber.Map{"error": "DB not available"}) + } + var b crudBody + if err := sonic.Unmarshal(c.Body(), &b); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "insert failed"}) + } + if b.Name == "" { + b.Name = "New Product" + } + if b.Category == "" { + b.Category = "test" + } + ctx, cancel := dbContext() + defer cancel() + var id int + err := pgPool.QueryRow(ctx, + `INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) + VALUES ($1, $2, $3, $4, $5, true, '["bench"]', 0, 0) + ON CONFLICT (id) DO UPDATE SET name = $2, price = $4, quantity = $5 RETURNING id`, + b.ID, b.Name, b.Category, b.Price, b.Quantity).Scan(&id) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "insert failed"}) + } + // ON CONFLICT makes this an upsert, so it can move a row a previous read + // already cached. Same invalidation the update path does. + if rdb != nil { + rdb.Del(ctx, "crud:"+strconv.Itoa(id)) + } + return c.Status(201).JSON(fiber.Map{"id": id, "name": b.Name, + "category": b.Category, "price": b.Price, "quantity": b.Quantity}) +} + +// Cache-aside on Redis where a REDIS_URL is provided. Nothing in a single +// container run provides one - the harness passes it only to the compose +// stacks - so in practice this reads straight through to Postgres. +func crudRead(c fiber.Ctx) error { + if pgPool == nil { + return c.Status(500).JSON(fiber.Map{"error": "DB not available"}) + } + id := fiber.Params[int](c, "id", -1) + if id < 0 { + return c.SendStatus(fiber.StatusNotFound) + } + ctx, cancel := dbContext() + defer cancel() + key := "crud:" + strconv.Itoa(id) + if rdb != nil { + if hit, err := rdb.Get(ctx, key).Result(); err == nil && hit != "" { + c.Set("X-Cache", "HIT") + c.Set("Content-Type", "application/json") + return c.SendString(hit) + } + } + items, err := queryItems(ctx, "SELECT "+itemColumns+" FROM items WHERE id = $1 LIMIT 1", id) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "query failed"}) + } + if len(items) == 0 { + return c.SendStatus(404) + } + body, _ := sonic.Marshal(items[0]) + if rdb != nil { + rdb.Set(ctx, key, body, crudTTL) + } + c.Set("X-Cache", "MISS") + c.Set("Content-Type", "application/json") + return c.Send(body) +} + +func crudUpdate(c fiber.Ctx) error { + if pgPool == nil { + return c.Status(500).JSON(fiber.Map{"error": "DB not available"}) + } + id := fiber.Params[int](c, "id", -1) + if id < 0 { + return c.SendStatus(fiber.StatusNotFound) + } + var b crudBody + if err := sonic.Unmarshal(c.Body(), &b); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "update failed"}) + } + if b.Name == "" { + b.Name = "Updated" + } + ctx, cancel := dbContext() + defer cancel() + tag, err := pgPool.Exec(ctx, + "UPDATE items SET name = $1, price = $2, quantity = $3 WHERE id = $4", + b.Name, b.Price, b.Quantity, id) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "update failed"}) + } + if tag.RowsAffected() == 0 { + return c.SendStatus(404) + } + if rdb != nil { + rdb.Del(ctx, "crud:"+strconv.Itoa(id)) + } + return c.JSON(fiber.Map{"id": id, "name": b.Name, "price": b.Price, "quantity": b.Quantity}) +} + +var mimeTypes = map[string]string{ + ".css": "text/css", ".js": "application/javascript", ".html": "text/html", + ".woff2": "font/woff2", ".svg": "image/svg+xml", ".webp": "image/webp", + ".json": "application/json", +} + +// What the static profiles require is that the response follow the disk: +// replace a file and the next response carries the new bytes. Serving from +// memory is allowed in every mode, but only through a cache that is the +// framework's own - so this reads per request and holds no copy of its own. +// +// Fiber's own static middleware is not an option for either half of that. +// Its cache holds open file handles and never re-stats them, so a file replaced +// on disk keeps being served for up to CacheDuration - which is the one thing +// the profile checks. And its Compress option generates .fiber.br twins next to +// the originals rather than reading the .br/.gz ones already there, on a +// directory the harness mounts read-only. +// +// So the twins are picked up here. The profile allows selecting them off +// Accept-Encoding where a framework has no API of its own for it; those bytes +// exist on disk either way, which makes this a file read rather than +// compression. It is also the difference between answering the 20-file rotation +// with the 1.21 MB the originals weigh and the 318 KB the twins do, because the +// compress middleware sits this round out: fasthttp's Accept-Encoding matcher +// compares whole tokens, and the profile sends "br;q=1, gzip;q=0.8". +func staticFile(c fiber.Ctx) error { + name := c.Params("filename") + if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") { + return c.SendStatus(fiber.StatusNotFound) + } + path := "/data/static/" + name + + var data []byte + enc := "" + // Fiber's own negotiation reads the q-values the way RFC 9110 says, which + // is the whole difficulty here. It is guarded on the header being present + // because with no Accept-Encoding at all a negotiator answers with the + // first offer, and that would encode a body for a client that never asked. + if c.Get(fiber.HeaderAcceptEncoding) != "" { + switch c.AcceptsEncodings("br", "gzip") { + case "br": + if b, err := os.ReadFile(path + ".br"); err == nil { + data, enc = b, "br" + } + case "gzip": + if b, err := os.ReadFile(path + ".gz"); err == nil { + data, enc = b, "gzip" + } + } + } + if enc == "" { + b, err := os.ReadFile(path) + if err != nil { + return c.SendStatus(fiber.StatusNotFound) + } + data = b + } + + // The Content-Type is the original file's either way; only the encoding + // changes. Set before Send so the compress middleware sees a body that is + // already encoded and leaves it alone. + ct := mimeTypes[filepath.Ext(name)] + if ct == "" { + ct = "application/octet-stream" + } + c.Set(fiber.HeaderContentType, ct) + if enc != "" { + c.Set(fiber.HeaderContentEncoding, enc) + c.Set(fiber.HeaderVary, fiber.HeaderAcceptEncoding) + } + return c.Send(data) +} + +func main() { + // The master process supervises children and nothing else: it binds no + // socket and serves no request, so the dataset, the pool and the cache + // client belong in the children. Each of those re-runs main() from the top + // with the marker environment variable set, which is what fiber.IsChild + // reads - and loading the pool there rather than here is also what keeps a + // live connection out of the process that forks. + serving := fiber.IsChild() + if serving { + loadDataset() + loadPgPool() + loadRedis() + pretouchJSON() + } + + // The one Config the standard entry does without. sonic replaces + // encoding/json behind c.JSON - tuned mode names alternative serializers + // first among what it allows - and it is the JIT build, not the compat + // one: on amd64 and arm64 sonic's build tags select it for every Go from + // 1.17 up to, not including, 1.28. Its output for these types is the same + // bytes encoding/json writes; the one default that differs, HTML escaping, + // has nothing to act on in the dataset. Everything else in Config stays at + // the framework's default, the 4 MB body limit included. + app := fiber.New(fiber.Config{ + JSONEncoder: sonic.Marshal, + JSONDecoder: sonic.Unmarshal, + }) + + // Compression is mounted on the two routes with a body worth compressing + // rather than on the whole app, as in the standard entry. The level is + // what differs: best speed rather than the default. json-comp scores + // requests per second for a body that is serialized and compressed per + // request, and the profile requires valid gzip or brotli, not a ratio - + // the compress middleware picks brotli for the profile's "gzip, br", so + // this is brotli level 0 instead of fasthttp's default 4, and gzip level 1 + // instead of 6 for a client that accepts only gzip. On a sandbox core the + // brotli step for the 8.4 KB /json/50 body is ~259 us at the default on + // the release's library, ~107 us at the default on go-brrr, and ~26 us at + // level 0 on go-brrr, at 1490 -> 1944 bytes on the wire. Static is + // unaffected either way: the twins on disk are compressed already, and the + // middleware leaves an encoded body alone. + app.Use([]string{"/json", "/static"}, compress.New(compress.Config{ + Level: compress.LevelBestSpeed, + })) + + app.Get("/pipeline", pipeline) + app.Get("/baseline11", baseline11) + app.Post("/baseline11", baseline11) + app.Get("/json/:count", jsonItems) + app.Post("/echo", echoBody) + app.Get("/baseline2", baseline11) + app.Get("/delay/:ms", delay) + app.Get("/static/:filename", staticFile) + app.Get("/async-db", asyncDb) + app.Get("/crud/items", crudList) + app.Post("/crud/items", crudCreate) + app.Get("/crud/items/:id", crudRead) + app.Put("/crud/items/:id", crudUpdate) + + listen := fiber.ListenConfig{ + DisableStartupMessage: true, + EnablePrefork: true, + } + + var signalled context.Context + if serving { + // A worker signalled directly finishes the requests it is holding + // before it exits. That is the path fasthttp's own prefork teardown + // takes: when the master stops supervising it SIGTERMs its children and + // waits for them. + // + // The waiting has to happen here rather than in ListenConfig's + // GracefulContext, which shuts the listener down in a goroutine while + // Listen returns straight away - main then exits and takes the + // in-flight response with it. Measured: with the wait below a request + // signalled 0.4s into a 1.5s handler still answers 200 at 1.5s; without + // it the client's connection dies at 0.4s. + // + // `docker stop` is a different path and does not drain: it signals + // PID 1, which in this container is the prefork master. The master + // serves nothing and deliberately holds no handler of its own - taking + // the signal over from the runtime there would keep it alive until + // Docker gave up waiting and escalated to SIGKILL - so it exits, and + // the kernel kills the workers with it, PID 1 of a namespace taking the + // namespace with it. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + signalled = ctx + drained = make(chan struct{}) + go func() { + <-ctx.Done() + // Inside the 5s the prefork master waits for a signalled child + // before it kills it (PreforkShutdownGracePeriod), so a handler + // that will not finish cannot turn a teardown into a SIGKILL. + if err := app.ShutdownWithTimeout(shutdownGrace); err != nil { + log.Printf("shutdown: %v", err) + } + close(drained) + }() + + // json-tls, static-tls and 8gbit on 8081, the same app behind TLS. The + // harness mounts /certs for every run, so the files being there is what + // says the listener is wanted. + const cert, key = "/certs/server.crt", "/certs/server.key" + _, certErr := os.Stat(cert) + _, keyErr := os.Stat(key) + if certErr == nil && keyErr == nil { + // The keypair is loaded here rather than handed to Fiber as + // CertFile/CertKeyFile, because that path installs a TLSHandler + // whose GetCertificate callback writes the ClientHello onto one + // shared struct on every handshake, unsynchronised (fiber + // ctx.go:95-98, wired at listen.go:233-241). `go build -race` + // reports it as a data race under concurrent handshakes, and the + // three profiles on this port drive 512 to 16384 connections. + // Nothing here reads that ClientHello. Passing TLSConfig takes the + // branch that clones the config as given and installs no handler; + // the fields are the ones Fiber's own CertFile path would have set. + // NextProtos stays unset, as it is on that path and in go-fasthttp: + // the profile's TLS probe accepts a server that omits ALPN ("none + // negotiated, client falls back"), and a server advertising only + // http/1.1 would answer a client that offers only h2 with a failed + // handshake, where omitting the extension lets it fall back. + pair, pairErr := tls.LoadX509KeyPair(cert, key) + if pairErr != nil { + log.Printf("tls keypair: %v", pairErr) + } else { + go func() { + // In a child, EnablePrefork means "take the SO_REUSEPORT socket + // for this address", not "fork again": fasthttp checks the + // child marker before it looks at anything else. Without it + // every worker would race for an ordinary bind on 8081 and all + // but one would lose it - silently, back when this dropped the + // error instead of logging it. + err := app.Listen(":8081", fiber.ListenConfig{ + DisableStartupMessage: true, + EnablePrefork: true, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{pair}, + }, + }) + if err != nil { + log.Printf("tls listener on :8081: %v", err) + } + }() + } + } + } + + err := app.Listen(":8080", listen) + + // Listen returns for two reasons, and they need opposite answers. After a + // signal it means the shutdown started, and the process has to stay up + // until the drain finishes. Otherwise the listener failed - or, in the + // master, prefork gave up replacing children - and the container should + // exit rather than sit there answering nothing. + if signalled == nil || signalled.Err() == nil { + if err != nil { + log.Printf("listener on :8080: %v", err) + } + os.Exit(1) + } + <-drained +} diff --git a/frameworks/fiber-tuned/meta.json b/frameworks/fiber-tuned/meta.json new file mode 100644 index 000000000..a03a845ac --- /dev/null +++ b/frameworks/fiber-tuned/meta.json @@ -0,0 +1,36 @@ +{ + "display_name": "fiber-tuned", + "language": "Go", + "type": "flagship", + "mode": "tuned", + "completeness": { + "routing": true, + "middleware": true, + "request": true, + "response": true + }, + "engine": "fasthttp", + "description": "Fiber 3 on fasthttp, the fiber entry with what it leaves at default: sonic behind c.JSON, the compress middleware at best speed (brotli 0, gzip 1), the Postgres pool filled at startup, and fasthttp at master for its go-brrr brotli. Same routes, prefork, static and TLS paths as fiber.", + "repo": "https://github.com/gofiber/fiber", + "enabled": true, + "tests": [ + "baseline", + "latency-1m", + "latency-10k", + "pipelined", + "limited-conn", + "async", + "json-comp", + "json-tls", + "8gbit", + "static-tls", + "async-db" + ], + "maintainers": [ + "ReneWerner87", + "gaby", + "sixcolors", + "efectn", + "Fenny" + ] +} diff --git a/frameworks/fiber/Dockerfile b/frameworks/fiber/Dockerfile index ddfc9d41c..c7a7ee24c 100644 --- a/frameworks/fiber/Dockerfile +++ b/frameworks/fiber/Dockerfile @@ -7,5 +7,5 @@ RUN CGO_ENABLED=0 go build -o server main.go FROM alpine:3.23 COPY --from=build /app/server /server -EXPOSE 8080 +EXPOSE 8080 8081 CMD ["/server"] diff --git a/frameworks/fiber/README.md b/frameworks/fiber/README.md index f4b20ab7b..5f514242c 100644 --- a/frameworks/fiber/README.md +++ b/frameworks/fiber/README.md @@ -1,10 +1,10 @@ # fiber -Fiber web framework on fasthttp, default configuration. +Fiber web framework on fasthttp, with prefork for multi-core scaling. ## Stack -- **Language:** Go 1.26 +- **Language:** Go — `go 1.25.0` in go.mod, built with the 1.26 toolchain - **Framework:** Fiber 3 - **Build:** `golang:1.26-alpine` -> `alpine:3.23` runtime @@ -13,28 +13,154 @@ Fiber web framework on fasthttp, default configuration. | Endpoint | Method | Description | |----------|--------|-------------| | `/pipeline` | GET | Returns `ok` (plain text) | -| `/baseline11` | GET | Sums query parameter values | -| `/baseline11` | POST | Sums query parameters + request body | +| `/baseline11` | GET | Sums the `a` and `b` query parameters | +| `/baseline11` | POST | Sums the query parameters + the request body | +| `/baseline2` | GET | The same handler under the name the HTTP/2 profiles use. Kept for parity with the other Go entries; fasthttp speaks no HTTP/2, so nothing here drives it | | `/json/{count}?m=N` | GET | First `count` dataset items with `total = price * quantity * m` | -| `/echo` | POST | Returns the request body back verbatim | +| `/echo` | POST | Returns the request body verbatim. `8gbit` posts to it over TLS on 8081 | +| `/delay/{ms}` | GET | Answers `ms` after waiting that long | +| `/static/{file}` | GET | Serves `/data/static`, pre-compressed twin where the client takes one | +| `/async-db` | GET | Items in a price range from Postgres (`min`, `max`, `limit`) | +| `/crud/items` | GET, POST | List by category; upsert by id | +| `/crud/items/{id}` | GET, PUT | Read through the cache; update and invalidate | ## Notes -- Routing and path/query access through the Fiber API -- JSON through `c.JSON`, serialized per request -- Compression through the Fiber `compress` middleware -- Body limit raised to 25 MB so the in-out profile is not rejected +- **No `fiber.Config`.** The app is built with `fiber.New()`, so every setting the App itself + takes is the framework's own default — the 4 MB body limit included, which is forty times the + largest body anything sends this entry (100 KB, in the `8gbit` validation). What is not + default is in `ListenConfig`: `EnablePrefork` (the section below) and `DisableStartupMessage`, + which only silences a banner. +- One worker process per CPU the container is given, through Fiber's `EnablePrefork`: the + master binds nothing and every worker accepts on its own `SO_REUSEPORT` socket. The section + below has the mechanics and what it costs. +- Routing and binding through the Fiber API: `/baseline11` reads its two operands with + `fiber.Query[int]`, `/json/{count}`, `/delay/{ms}` and `/crud/items/{id}` read their path + parameter with `fiber.Params[int]`, and `/async-db` and `/crud/items` read theirs with + `fiber.Query[int]` as well. No request materialises the query string into a map. +- JSON through `c.JSON`, serialized per request. +- The `compress` middleware is mounted on `/json` and `/static` rather than on the whole app. + What that leaves out is either answered in a handful of bytes — `/pipeline`, `/baseline11`, + `/delay/{ms}`, `/baseline2`, where fasthttp's 200-byte floor means the middleware could only + stamp a `Vary` header on the endpoints the throughput and CPU-per-request profiles drive — or + wanted back unchanged, which is `/echo`. `/async-db` and `/crud/*` are outside it too; their + profiles send no `Accept-Encoding`, so nothing would have compressed there anyway. +- A worker that is signalled directly finishes the requests it is holding before it exits: a + request signalled 0.4 s into a 1.5 s handler still answers 200 at 1.5 s. `docker stop` is a + different path and does not drain — it signals PID 1, which here is the prefork master, and + when the init of a PID namespace exits the kernel takes the rest of the namespace with it. ## Added profiles -`static`, `static-tls`, `json-tls`, `async-db` and `crud`. - -- `json-tls` and `static-tls` listen on `8081` when `/certs/server.crt` and `/certs/server.key` - are mounted; it is the same router behind TLS, not a second copy of the handlers. -- Static file bodies are read from disk on every request, which the static profiles require in - every mode. Standard mode leaves the encoding to the compression middleware rather than - serving a pre-compressed sibling. -- Postgres goes through `pgx`. One process here, so the whole `DATABASE_MAX_CONN` budget is - available, less headroom for `superuser_reserved_connections`. -- `crud` runs cache-aside on Redis with a 200ms TTL and an explicit delete on update. -- `tags` is a JSONB column, so it comes back as bytes rather than a Go slice. +`async`, `json-tls`, `static-tls` and `async-db`. The `/crud/*` routes are still served, but no +profile drives them any more. + +- `json-tls`, `static-tls` and `8gbit` share the listener on `8081`, opened when + `/certs/server.crt` and `/certs/server.key` are both present. It is the same router behind + TLS, not a second copy of the handlers. The keypair is loaded here and handed over as + `ListenConfig.TLSConfig` rather than as `CertFile`/`CertKeyFile`: that convenience path + installs a `TLSHandler` whose `GetCertificate` callback writes the ClientHello onto one + shared struct on every handshake, which `go build -race` reports as a data race under + concurrent handshakes — and this port is driven at 512 to 16,384 connections. +- What the static profiles require is that the response follow the disk: replace a file and the + next response carries the new bytes. Serving from memory is allowed in every mode, but only + through a cache that is the framework's own — and Fiber's static middleware cannot be that + cache here, because it holds open file handles and never re-stats them, so a replaced file + keeps being served for up to `CacheDuration`, and its `Compress` option writes `.fiber.br` + twins into a directory the harness mounts read-only. So this entry reads per request and + holds no copy of its own. +- Where the client accepts an encoding, the `.br`/`.gz` twin the harness ships beside the file + is read instead of the original, picked with Fiber's own `AcceptsEncodings`. The profile + allows selecting the variant off `Accept-Encoding` where a framework has no API for it, and + the alternative is not compression but the rotation going out at full size: the 20 files + weigh 1.21 MB as originals and 318 KB as the twins the client is offered, and the compress + middleware sits the round out because fasthttp's Accept-Encoding matcher compares whole + tokens while the profile sends `br;q=1, gzip;q=0.8`. +- `/delay/{ms}` parks the handler's goroutine on a timer, which is what makes `async` a + question about the process rather than about the handler. +- Postgres goes through `pgx`, and every database and cache call carries a deadline: Fiber's + `Ctx.Context()` is a background context and fasthttp has no per-request cancellation to put + in it, so nothing else would bound a query whose client has gone away. +- The crud read is cache-aside on Redis with a 200 ms TTL and an explicit delete on update, + when `REDIS_URL` is set. The harness passes one only to the compose stacks, so in a + single-container run this reads straight through to Postgres. +- `tags` is a JSONB column, so it arrives as bytes rather than as a Go slice. + +## Prefork + +`app.Listen(":8080", fiber.ListenConfig{EnablePrefork: true})` hands off to fasthttp's prefork +manager: one worker process per CPU the container is given. + +### Why this is standard and not tuned + +- The mode's own rule page allows it twice: "Worker/thread counts matching available CPU cores" + under **Allowed**, and "Setting worker count to match CPU cores" under deployment-environment + tuning. Its **Not allowed** list — undocumented flags, experimental options, settings that + disable buffering or validation — covers none of this. `EnablePrefork` is a documented public + field, and Fiber's own documentation carries deployment guidance for it (run it inside a + trusted boundary, prefer container isolation), which is exactly one benchmark container. +- The board already runs this way. `express`, `fastify` and `koa` fork one cluster worker per + core, `aiohttp` describes itself as "one forked worker per core sharing the port with + SO_REUSEPORT", and all of them are `mode: standard`. Counted in the sources rather than in + the prose — a `SO_REUSEPORT` socket, a `prefork` manager or a `cluster.fork` in an entry's + own code or build files — 34 standard entries run this way, this one included. +- The socket options that come with it are settled here too. `go-fasthttp` — flagship, standard, + on the same profiles — calls the identical `reuseport.Listen`, and `axum` added one + `SO_REUSEPORT` listener per core in + [#1361](https://github.com/MDA2AV/HttpArena/pull/1361) and stayed standard. + +The counter-argument, stated because a reviewer will find it: the `baseline` profile's standard +rule reads narrower than the mode page — "No custom TCP tuning, no experimental flags, no worker +count beyond framework defaults." Two pages, one question, opposite answers. It resolves the way +it does here for two reasons. `standard.md` is the mode's own rules page and is explicit twice +over, while the profile string is a one-line summary of it; and the socket options are not this +entry's to begin with — `reuseport.Listen` is what fasthttp's own prefork path calls, so what a +reviewer would be pricing is a framework default rather than something this entry set — the +distinction `standard.md` draws in its static-file section, "what the framework gives you, not +what can be written against it", applied to sockets instead of caches. Taking the narrow reading +instead does not reclassify this entry alone: it reclassifies the other 33, `go-fasthttp` and +`axum` among them. + +`short-lived` is sometimes cited here too and does not belong in the argument: its standard rule +is about keep-alive and connection pooling ("Must use the framework default connection handling. +No custom keep-alive tuning or connection pooling optimizations"), and what its tuned side lists +as permitted for tuned entries says nothing about what standard ones may do. + +- The master binds nothing. It re-executes the binary `GOMAXPROCS` times with + `FASTHTTP_PREFORK_CHILD=1` set, then supervises: a worker that dies is replaced, until the + cumulative number of exits passes `PreforkRecoverThreshold` (Fiber defaults it to + `max(1, GOMAXPROCS/2)`), at which point the master gives up and the container exits with it. +- On the benchmark cpuset (`0-31,64-95` — 32 physical cores, 64 hardware threads) that is 64 + workers, each dropped to `GOMAXPROCS(1)` by prefork itself: one Go runtime per hardware + thread rather than one runtime scheduling all of them. +- Each worker binds its own socket through `reuseport.Listen` and the kernel spreads accepted + connections across them. That listener also carries `TCP_DEFER_ACCEPT` and `TCP_FASTOPEN`, + which are fasthttp's defaults for a reuseport socket rather than anything this entry sets. +- The TLS listener on `8081` is opened the same way, once per worker: in a child + `EnablePrefork` means "take the `SO_REUSEPORT` socket for this address", not "fork again". + Without it every worker would race for an ordinary bind and all but one would lose it. +- The dataset, the Postgres pool and the Redis client are loaded only where `fiber.IsChild()` + is true. The master has no use for them, and a pool opened before the fork would hand the + same connections to every worker. +- Anything the container holds once is divided by the worker count. The Postgres pool is the + one that matters: `DATABASE_MAX_CONN` is a budget for the container, not for a process. + +What it costs is one Go runtime per hardware thread — N heaps, N garbage collectors, N sets of +background threads — paid for whether or not requests are arriving. The memory figure carries +that directly. Whether it also lands on `latency-1m` and `latency-10k`, which price CPU per +request at a fixed rate, is what those profiles are for: it depends on whether the workers get a +core each, and here they do — the harness pins the server to `0-31,64-95` and the load generator +to the other half of the chip. `/benchmark -f fiber` reports the deltas against this entry's +results on `main`, so the trade is a number rather than an argument. + +## Tuned sibling + +Four things this entry leaves at the framework's defaults — sonic behind `c.JSON`, the compress +middleware at its best-speed level, the Postgres pool filled at startup, and fasthttp at `master` +for the go-brrr brotli it swapped in — are set in the [`fiber-tuned`](../fiber-tuned/) entry, +`mode: tuned`. sonic is the one standard mode forbids. +The compress level is an option of the same middleware, and `carter` and `salvo` run theirs at +level 1 in standard mode; it stays at default here so that "default configuration" stays true, +and its README has the numbers: on a sandbox core, json-comp costs this entry roughly 380 µs per +request, about 260 of them brotli at fasthttp's default level 4. The board runs both entries, so +each setting is a delta against this one rather than a claim. diff --git a/frameworks/fiber/main.go b/frameworks/fiber/main.go index f1e084c03..d940740c6 100644 --- a/frameworks/fiber/main.go +++ b/frameworks/fiber/main.go @@ -2,12 +2,16 @@ package main import ( "context" + "crypto/tls" "encoding/json" + "log" "os" + "os/signal" "path/filepath" "runtime" "strconv" "strings" + "syscall" "time" "github.com/gofiber/fiber/v3" @@ -16,7 +20,24 @@ import ( "github.com/redis/go-redis/v9" ) -const maxBody = 25 * 1024 * 1024 +// How long a worker keeps serving after it is signalled, before it stops +// waiting for the requests still in flight. +const shutdownGrace = 3 * time.Second + +// Closed once the shutdown started by a signal has finished, so main can wait +// for it. Nil when this process is not serving. +var drained chan struct{} + +// workerProcesses is how many processes end up sharing anything the container +// holds once - the connection budget below, most of all. +// +// Prefork forks GOMAXPROCS children, the value read in the master before it +// spawns anything. A child re-runs main() from the top and reads the same +// number here, because the GOMAXPROCS(1) that prefork applies to a child +// happens later, when it takes its listener. +func workerProcesses() int { + return runtime.GOMAXPROCS(0) +} type Rating struct { Score int `json:"score"` @@ -51,24 +72,30 @@ func loadDataset() { if path == "" { path = "/data/dataset.json" } + // Logged rather than swallowed: with no dataset every /json/{count} answers + // 200 with an empty list, which looks like a working server right up until + // the numbers are compared against the file. data, err := os.ReadFile(path) if err != nil { + log.Printf("dataset %s: %v", path, err) return } - json.Unmarshal(data, &dataset) + if err := json.Unmarshal(data, &dataset); err != nil { + log.Printf("dataset %s: %v", path, err) + } } func pipeline(c fiber.Ctx) error { return c.SendString("ok") } +// The profile sends a and b and nothing else, so they are read by name through +// Fiber's typed query binder rather than materialising the whole query string +// into a map. Both are hot: baseline drives this endpoint at 4096 connections +// and latency-1m and latency-10k score what a request costs in CPU, where one +// map allocation per request is a line item. func baseline11(c fiber.Ctx) error { - sum := 0 - for _, v := range c.Queries() { - if n, err := strconv.Atoi(v); err == nil { - sum += n - } - } + sum := fiber.Query[int](c, "a") + fiber.Query[int](c, "b") if c.Method() == fiber.MethodPost { if n, err := strconv.Atoi(strings.TrimSpace(string(c.Body()))); err == nil { sum += n @@ -77,16 +104,41 @@ func baseline11(c fiber.Ctx) error { return c.SendString(strconv.Itoa(sum)) } +// The longest wait this will serve. The profile asks for 10ms and validation +// for at most half a second; the cap is here because time.Duration(ms) * +// time.Millisecond overflows int64 past about 292 years' worth of milliseconds, +// and an overflowed duration is negative, so the handler answers immediately - +// the one answer this endpoint is not allowed to give. +const maxDelayMillis = int(time.Hour / time.Millisecond) + +// GET /delay/{ms}: answer no earlier than the wait named in the path. A +// goroutine parked on a timer is what Fiber gives you for free here - the +// handler blocks, the process does not. +func delay(c fiber.Ctx) error { + ms := fiber.Params[int](c, "ms", -1) + if ms < 0 || ms > maxDelayMillis { + return c.SendStatus(fiber.StatusNotFound) + } + if ms > 0 { + time.Sleep(time.Duration(ms) * time.Millisecond) + } + return c.SendString(strconv.Itoa(ms)) +} + func jsonItems(c fiber.Ctx) error { - count, _ := strconv.Atoi(c.Params("count")) + count := fiber.Params[int](c, "count", 0) if count < 0 { count = 0 } if count > len(dataset) { count = len(dataset) } - m, err := strconv.Atoi(c.Query("m")) - if err != nil || m == 0 { + // An explicit m=0 reads as "not given", the same as an absent or unparsable + // one. Every entry in the repo does this and the profiles only ever send + // m >= 1, so the alternative is a column of zero totals that nothing asks + // for and that no other row would report. + m := fiber.Query[int](c, "m", 1) + if m == 0 { m = 1 } @@ -110,12 +162,21 @@ var rdb *redis.Client const itemColumns = "id, name, category, price, quantity, active, tags, rating_score, rating_count" -// The crud profile reads and writes the same ids, so a long TTL would answer -// from a copy the writes have already moved past. +// The crud routes read and write the same ids, so a long TTL would answer from +// a copy the writes have already moved past. No profile drives them any more; +// the TTL is kept at what the workload they were built for needed. const crudTTL = 200 * time.Millisecond -// One process here, so the whole connection budget is ours - but Postgres runs -// with max_connections=256 and reserves a few of those for the superuser. +// The pool is sized from DATABASE_MAX_CONN and from nothing else, which is what +// the async-db profile requires of a standard entry: "Size the pool from +// DATABASE_MAX_CONN (currently 256), not from CPU count." +// +// The 8 subtracted below is a safety margin rather than an exact figure: the +// server keeps 3 connections back for the superuser by default, and the +// harness's own psql and pg_isready probes want one now and then. The remainder +// is divided by the worker count because the budget belongs to the container, +// not to a process, and every child opens a pool of its own against the same +// server - undivided, the fleet would ask for sixty-four times what it can get. func loadPgPool() { url := os.Getenv("DATABASE_URL") if url == "" { @@ -123,6 +184,7 @@ func loadPgPool() { } cfg, err := pgxpool.ParseConfig(url) if err != nil { + log.Printf("database url: %v", err) return } budget := 256 @@ -131,16 +193,15 @@ func loadPgPool() { budget = n } } - maxConns := budget - 8 - if m := runtime.NumCPU() * 4; m < maxConns { - maxConns = m - } + workers := workerProcesses() + maxConns := (budget - 8) / workers if maxConns < 1 { maxConns = 1 } cfg.MaxConns = int32(maxConns) pool, err := pgxpool.NewWithConfig(context.Background(), cfg) if err != nil { + log.Printf("database pool: %v", err) return } pgPool = pool @@ -153,6 +214,7 @@ func loadRedis() { } opt, err := redis.ParseURL(url) if err != nil { + log.Printf("redis url: %v", err) return } rdb = redis.NewClient(opt) @@ -171,24 +233,26 @@ func queryItems(ctx context.Context, sql string, args ...any) ([]DatasetItem, er var tags []byte if err := rows.Scan(&it.ID, &it.Name, &it.Category, &it.Price, &it.Quantity, &it.Active, &tags, &it.Rating.Score, &it.Rating.Count); err != nil { - continue + return nil, err + } + if len(tags) > 0 { + if err := json.Unmarshal(tags, &it.Tags); err != nil { + return nil, err + } } - json.Unmarshal(tags, &it.Tags) if it.Tags == nil { it.Tags = []string{} } items = append(items, it) } - return items, nil -} - -func queryInt(c fiber.Ctx, name string, fallback int) int { - if v := c.Query(name); v != "" { - if n, err := strconv.Atoi(v); err == nil { - return n - } + // A connection that fails mid-iteration ends the loop like a clean finish + // does. Without this the handler would answer 200 with however many rows + // arrived before the error, which reads as a short result rather than a + // failed one. + if err := rows.Err(); err != nil { + return nil, err } - return fallback + return items, nil } func clamp(v, lo, hi int) int { @@ -201,19 +265,46 @@ func clamp(v, lo, hi int) int { return v } -var emptyItems = fiber.Map{"items": []DatasetItem{}, "count": 0} +// The async-db response is a struct rather than a fiber.Map: same JSON, without +// asking encoding/json to reflect over a map and sort its keys per request. +type itemsResponse struct { + Items []DatasetItem `json:"items"` + Count int `json:"count"` +} + +var emptyItems = itemsResponse{Items: []DatasetItem{}} + +// Every database and cache call is answered inside this deadline. +// +// Fiber's Ctx.Context() is a background context unless the application puts one +// there, and fasthttp has no per-request cancellation to put there either - a +// client that walks away mid-query leaves the query running and its pool +// connection held. A deadline is what bounds that, and it is what the net/http +// entries get from the request context for free. +const dbTimeout = 5 * time.Second + +func dbContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), dbTimeout) +} func asyncDb(c fiber.Ctx) error { if pgPool == nil { return c.JSON(emptyItems) } - items, err := queryItems(c.Context(), + ctx, cancel := dbContext() + defer cancel() + items, err := queryItems(ctx, "SELECT "+itemColumns+" FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3", - queryInt(c, "min", 10), queryInt(c, "max", 50), clamp(queryInt(c, "limit", 50), 1, 50)) + fiber.Query[int](c, "min", 10), fiber.Query[int](c, "max", 50), + clamp(fiber.Query[int](c, "limit", 50), 1, 50)) + // An empty list rather than a 500: it is the shape the profile documents and + // what every other entry answers here. It does mean a database that is down + // reads the same as a price range with nothing in it - validation tells them + // apart, because it asserts count == limit on ranges that do have rows. if err != nil { return c.JSON(emptyItems) } - return c.JSON(fiber.Map{"items": items, "count": len(items)}) + return c.JSON(itemsResponse{Items: items, Count: len(items)}) } func crudList(c fiber.Ctx) error { @@ -224,12 +315,14 @@ func crudList(c fiber.Ctx) error { if category == "" { category = "electronics" } - page := queryInt(c, "page", 1) + page := fiber.Query[int](c, "page", 1) if page < 1 { page = 1 } - limit := clamp(queryInt(c, "limit", 10), 1, 50) - items, err := queryItems(c.Context(), + limit := clamp(fiber.Query[int](c, "limit", 10), 1, 50) + ctx, cancel := dbContext() + defer cancel() + items, err := queryItems(ctx, "SELECT "+itemColumns+" FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3", category, limit, (page-1)*limit) if err != nil { @@ -260,8 +353,10 @@ func crudCreate(c fiber.Ctx) error { if b.Category == "" { b.Category = "test" } + ctx, cancel := dbContext() + defer cancel() var id int - err := pgPool.QueryRow(c.Context(), + err := pgPool.QueryRow(ctx, `INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) VALUES ($1, $2, $3, $4, $5, true, '["bench"]', 0, 0) ON CONFLICT (id) DO UPDATE SET name = $2, price = $4, quantity = $5 RETURNING id`, @@ -269,21 +364,28 @@ func crudCreate(c fiber.Ctx) error { if err != nil { return c.Status(500).JSON(fiber.Map{"error": "insert failed"}) } + // ON CONFLICT makes this an upsert, so it can move a row a previous read + // already cached. Same invalidation the update path does. + if rdb != nil { + rdb.Del(ctx, "crud:"+strconv.Itoa(id)) + } return c.Status(201).JSON(fiber.Map{"id": id, "name": b.Name, "category": b.Category, "price": b.Price, "quantity": b.Quantity}) } -// Cache-aside on Redis where the harness provides it - crud is the one profile -// that does. +// Cache-aside on Redis where a REDIS_URL is provided. Nothing in a single +// container run provides one - the harness passes it only to the compose +// stacks - so in practice this reads straight through to Postgres. func crudRead(c fiber.Ctx) error { if pgPool == nil { return c.Status(500).JSON(fiber.Map{"error": "DB not available"}) } - id, err := strconv.Atoi(c.Params("id")) - if err != nil { - return c.SendStatus(404) + id := fiber.Params[int](c, "id", -1) + if id < 0 { + return c.SendStatus(fiber.StatusNotFound) } - ctx := c.Context() + ctx, cancel := dbContext() + defer cancel() key := "crud:" + strconv.Itoa(id) if rdb != nil { if hit, err := rdb.Get(ctx, key).Result(); err == nil && hit != "" { @@ -312,9 +414,9 @@ func crudUpdate(c fiber.Ctx) error { if pgPool == nil { return c.Status(500).JSON(fiber.Map{"error": "DB not available"}) } - id, err := strconv.Atoi(c.Params("id")) - if err != nil { - return c.SendStatus(404) + id := fiber.Params[int](c, "id", -1) + if id < 0 { + return c.SendStatus(fiber.StatusNotFound) } var b crudBody if err := json.Unmarshal(c.Body(), &b); err != nil { @@ -323,7 +425,8 @@ func crudUpdate(c fiber.Ctx) error { if b.Name == "" { b.Name = "Updated" } - ctx := c.Context() + ctx, cancel := dbContext() + defer cancel() tag, err := pgPool.Exec(ctx, "UPDATE items SET name = $1, price = $2, quantity = $3 WHERE id = $4", b.Name, b.Price, b.Quantity, id) @@ -345,35 +448,102 @@ var mimeTypes = map[string]string{ ".json": "application/json", } -// Static bodies are read from disk on every request, which the static profiles -// require in every mode. Standard mode leaves the encoding to the compress -// middleware mounted above rather than serving a pre-compressed sibling. +// What the static profiles require is that the response follow the disk: +// replace a file and the next response carries the new bytes. Serving from +// memory is allowed in every mode, but only through a cache that is the +// framework's own - so this reads per request and holds no copy of its own. +// +// Fiber's own static middleware is not an option for either half of that. +// Its cache holds open file handles and never re-stats them, so a file replaced +// on disk keeps being served for up to CacheDuration - which is the one thing +// the profile checks. And its Compress option generates .fiber.br twins next to +// the originals rather than reading the .br/.gz ones already there, on a +// directory the harness mounts read-only. +// +// So the twins are picked up here. The profile allows selecting them off +// Accept-Encoding where a framework has no API of its own for it; those bytes +// exist on disk either way, which makes this a file read rather than +// compression. It is also the difference between answering the 20-file rotation +// with the 1.21 MB the originals weigh and the 318 KB the twins do, because the +// compress middleware sits this round out: fasthttp's Accept-Encoding matcher +// compares whole tokens, and the profile sends "br;q=1, gzip;q=0.8". func staticFile(c fiber.Ctx) error { name := c.Params("filename") if name == "" || strings.Contains(name, "/") || strings.Contains(name, "..") { - return c.SendStatus(404) + return c.SendStatus(fiber.StatusNotFound) + } + path := "/data/static/" + name + + var data []byte + enc := "" + // Fiber's own negotiation reads the q-values the way RFC 9110 says, which + // is the whole difficulty here. It is guarded on the header being present + // because with no Accept-Encoding at all a negotiator answers with the + // first offer, and that would encode a body for a client that never asked. + if c.Get(fiber.HeaderAcceptEncoding) != "" { + switch c.AcceptsEncodings("br", "gzip") { + case "br": + if b, err := os.ReadFile(path + ".br"); err == nil { + data, enc = b, "br" + } + case "gzip": + if b, err := os.ReadFile(path + ".gz"); err == nil { + data, enc = b, "gzip" + } + } } - data, err := os.ReadFile("/data/static/" + name) - if err != nil { - return c.SendStatus(404) + if enc == "" { + b, err := os.ReadFile(path) + if err != nil { + return c.SendStatus(fiber.StatusNotFound) + } + data = b } + + // The Content-Type is the original file's either way; only the encoding + // changes. Set before Send so the compress middleware sees a body that is + // already encoded and leaves it alone. ct := mimeTypes[filepath.Ext(name)] if ct == "" { ct = "application/octet-stream" } - c.Set("Content-Type", ct) + c.Set(fiber.HeaderContentType, ct) + if enc != "" { + c.Set(fiber.HeaderContentEncoding, enc) + c.Set(fiber.HeaderVary, fiber.HeaderAcceptEncoding) + } return c.Send(data) } func main() { - loadDataset() - loadPgPool() - loadRedis() - - app := fiber.New(fiber.Config{ - BodyLimit: maxBody, - }) - app.Use(compress.New()) + // The master process supervises children and nothing else: it binds no + // socket and serves no request, so the dataset, the pool and the cache + // client belong in the children. Each of those re-runs main() from the top + // with the marker environment variable set, which is what fiber.IsChild + // reads - and loading the pool there rather than here is also what keeps a + // live connection out of the process that forks. + serving := fiber.IsChild() + if serving { + loadDataset() + loadPgPool() + loadRedis() + } + + // No fiber.Config: nothing here needs a setting the framework does not + // already default to. The body limit in particular used to be raised to + // 25 MB for an upload profile that no longer exists - the default 4 MB is + // forty times the largest body anything now sends this entry, the 100 KB + // the 8gbit validation posts. + app := fiber.New() + + // Compression is mounted on the two routes with a body worth compressing + // rather than on the whole app. What that leaves out is either answered in + // a handful of bytes - /pipeline, /baseline11, /delay and /baseline2, where + // fasthttp's 200-byte floor means the middleware could only walk the chain + // and stamp a Vary header on the endpoints baseline, latency-1m and + // latency-10k drive - or wanted back unchanged, which is /echo. /async-db + // and /crud are outside it too, and their callers send no Accept-Encoding. + app.Use([]string{"/json", "/static"}, compress.New()) app.Get("/pipeline", pipeline) app.Get("/baseline11", baseline11) @@ -381,6 +551,7 @@ func main() { app.Get("/json/:count", jsonItems) app.Post("/echo", echoBody) app.Get("/baseline2", baseline11) + app.Get("/delay/:ms", delay) app.Get("/static/:filename", staticFile) app.Get("/async-db", asyncDb) app.Get("/crud/items", crudList) @@ -388,18 +559,108 @@ func main() { app.Get("/crud/items/:id", crudRead) app.Put("/crud/items/:id", crudUpdate) - // json-tls and static-tls on 8081, the same app behind TLS. The harness only - // mounts /certs for the TLS profiles, so without them it is not opened. - const cert, key = "/certs/server.crt", "/certs/server.key" - if _, err := os.Stat(cert); err == nil { - if _, err := os.Stat(key); err == nil { - go app.Listen(":8081", fiber.ListenConfig{ - DisableStartupMessage: true, - CertFile: cert, - CertKeyFile: key, - }) + listen := fiber.ListenConfig{ + DisableStartupMessage: true, + EnablePrefork: true, + } + + var signalled context.Context + if serving { + // A worker signalled directly finishes the requests it is holding + // before it exits. That is the path fasthttp's own prefork teardown + // takes: when the master stops supervising it SIGTERMs its children and + // waits for them. + // + // The waiting has to happen here rather than in ListenConfig's + // GracefulContext, which shuts the listener down in a goroutine while + // Listen returns straight away - main then exits and takes the + // in-flight response with it. Measured: with the wait below a request + // signalled 0.4s into a 1.5s handler still answers 200 at 1.5s; without + // it the client's connection dies at 0.4s. + // + // `docker stop` is a different path and does not drain: it signals + // PID 1, which in this container is the prefork master. The master + // serves nothing and deliberately holds no handler of its own - taking + // the signal over from the runtime there would keep it alive until + // Docker gave up waiting and escalated to SIGKILL - so it exits, and + // the kernel kills the workers with it, PID 1 of a namespace taking the + // namespace with it. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + signalled = ctx + drained = make(chan struct{}) + go func() { + <-ctx.Done() + // Inside the 5s the prefork master waits for a signalled child + // before it kills it (PreforkShutdownGracePeriod), so a handler + // that will not finish cannot turn a teardown into a SIGKILL. + if err := app.ShutdownWithTimeout(shutdownGrace); err != nil { + log.Printf("shutdown: %v", err) + } + close(drained) + }() + + // json-tls, static-tls and 8gbit on 8081, the same app behind TLS. The + // harness mounts /certs for every run, so the files being there is what + // says the listener is wanted. + const cert, key = "/certs/server.crt", "/certs/server.key" + _, certErr := os.Stat(cert) + _, keyErr := os.Stat(key) + if certErr == nil && keyErr == nil { + // The keypair is loaded here rather than handed to Fiber as + // CertFile/CertKeyFile, because that path installs a TLSHandler + // whose GetCertificate callback writes the ClientHello onto one + // shared struct on every handshake, unsynchronised (fiber + // ctx.go:95-98, wired at listen.go:233-241). `go build -race` + // reports it as a data race under concurrent handshakes, and the + // three profiles on this port drive 512 to 16384 connections. + // Nothing here reads that ClientHello. Passing TLSConfig takes the + // branch that clones the config as given and installs no handler; + // the fields are the ones Fiber's own CertFile path would have set. + // NextProtos stays unset, as it is on that path and in go-fasthttp: + // the profile's TLS probe accepts a server that omits ALPN ("none + // negotiated, client falls back"), and a server advertising only + // http/1.1 would answer a client that offers only h2 with a failed + // handshake, where omitting the extension lets it fall back. + pair, pairErr := tls.LoadX509KeyPair(cert, key) + if pairErr != nil { + log.Printf("tls keypair: %v", pairErr) + } else { + go func() { + // In a child, EnablePrefork means "take the SO_REUSEPORT socket + // for this address", not "fork again": fasthttp checks the + // child marker before it looks at anything else. Without it + // every worker would race for an ordinary bind on 8081 and all + // but one would lose it - silently, back when this dropped the + // error instead of logging it. + err := app.Listen(":8081", fiber.ListenConfig{ + DisableStartupMessage: true, + EnablePrefork: true, + TLSConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{pair}, + }, + }) + if err != nil { + log.Printf("tls listener on :8081: %v", err) + } + }() + } } } - app.Listen(":8080", fiber.ListenConfig{DisableStartupMessage: true}) + err := app.Listen(":8080", listen) + + // Listen returns for two reasons, and they need opposite answers. After a + // signal it means the shutdown started, and the process has to stay up + // until the drain finishes. Otherwise the listener failed - or, in the + // master, prefork gave up replacing children - and the container should + // exit rather than sit there answering nothing. + if signalled == nil || signalled.Err() == nil { + if err != nil { + log.Printf("listener on :8080: %v", err) + } + os.Exit(1) + } + <-drained } diff --git a/frameworks/fiber/meta.json b/frameworks/fiber/meta.json index 99a36e2e1..74965b4af 100644 --- a/frameworks/fiber/meta.json +++ b/frameworks/fiber/meta.json @@ -10,18 +10,25 @@ "response": true }, "engine": "fasthttp", - "description": "Fiber 3 on fasthttp, default configuration. Routing and path/query binding through the Fiber API, JSON via c.JSON, gzip through the Fiber compress middleware.", + "description": "Fiber 3 on fasthttp, default configuration, one prefork child per logical CPU sharing the port. Routing and typed path/query binding through the Fiber API, JSON via c.JSON, gzip and brotli through the compress middleware, static files from the pre-compressed .br/.gz siblings on disk.", "repo": "https://github.com/gofiber/fiber", "enabled": true, "tests": [ "baseline", "latency-1m", "latency-10k", "pipelined", "limited-conn", + "async", "json-comp", "json-tls", "8gbit", "static-tls", "async-db" ], - "maintainers": [] + "maintainers": [ + "ReneWerner87", + "gaby", + "sixcolors", + "efectn", + "Fenny" + ] } diff --git a/site/content/docs/test-profiles/h1/static-tls/implementation.md b/site/content/docs/test-profiles/h1/static-tls/implementation.md index bc9773682..44db93071 100644 --- a/site/content/docs/test-profiles/h1/static-tls/implementation.md +++ b/site/content/docs/test-profiles/h1/static-tls/implementation.md @@ -24,7 +24,7 @@ The Static Files over TLS profile is the [Static Files](../static/implementation - **Images** (3 files, 6–45 KB): `hero.webp`, `thumb1.webp`, `thumb2.webp` - **JSON** (1 file, 3 KB): `manifest.json` -Total payload: ~842 KB across 20 files (~743 KB compressible text + ~99 KB binary). Brotli-compressed total: ~219 KB. +Total payload: ~1242 KB across 20 files (~1143 KB compressible text + ~99 KB binary). Brotli-compressed total: ~219 KB for the text; ~318 KB across all 20 files, since the five binary ones ship no pre-compressed twin. Pre-compressed versions of all text files (`.gz` at level 9, `.br` at level 11) are available in the `data/static/` directory alongside the originals. diff --git a/site/data/frameworks.json b/site/data/frameworks.json index 87140146a..f2a5f95b8 100644 --- a/site/data/frameworks.json +++ b/site/data/frameworks.json @@ -518,9 +518,23 @@ "type": "engine", "engine": "libuv" }, + "fiber-tuned": { + "dir": "fiber-tuned", + "description": "Fiber 3 on fasthttp, the fiber entry with what it leaves at default: sonic behind c.JSON, the compress middleware at best speed (brotli 0, gzip 1), the Postgres pool filled at startup, and fasthttp at master for its go-brrr brotli. Same routes, prefork, static and TLS paths as fiber.", + "repo": "https://github.com/gofiber/fiber", + "type": "flagship", + "engine": "fasthttp", + "mode": "tuned", + "completeness": { + "routing": true, + "middleware": true, + "request": true, + "response": true + } + }, "fiber": { "dir": "fiber", - "description": "Fiber 3 on fasthttp, default configuration. Routing and path/query binding through the Fiber API, JSON via c.JSON, gzip through the Fiber compress middleware.", + "description": "Fiber 3 on fasthttp, default configuration, one prefork child per logical CPU sharing the port. Routing and typed path/query binding through the Fiber API, JSON via c.JSON, gzip and brotli through the compress middleware, static files from the pre-compressed .br/.gz siblings on disk.", "repo": "https://github.com/gofiber/fiber", "type": "flagship", "engine": "fasthttp", From e8558d04917a642fab2360a0708000622adad928 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Sep 2026 18:02:56 +0000 Subject: [PATCH 2/2] Benchmark results: 2 frameworks (all tests) [skip ci] --- site/data/results/fiber-tuned.json | 255 ++++++++++++++++++ site/data/results/fiber.json | 232 ++++++++-------- site/static/logs/8gbit/512/fiber-tuned.log | 0 site/static/logs/8gbit/512/fiber.log | 0 .../static/logs/async-db/1024/fiber-tuned.log | 0 site/static/logs/async/32000/fiber-tuned.log | 0 site/static/logs/async/32000/fiber.log | 0 .../static/logs/baseline/4096/fiber-tuned.log | 0 .../logs/json-comp/16384/fiber-tuned.log | 0 .../logs/json-comp/4096/fiber-tuned.log | 0 .../static/logs/json-tls/4096/fiber-tuned.log | 0 .../logs/latency-10k/1024/fiber-tuned.log | 0 .../logs/latency-1m/1024/fiber-tuned.log | 0 .../logs/limited-conn/4096/fiber-tuned.log | 0 .../logs/pipelined/4096/fiber-tuned.log | 0 .../logs/static-tls/1024/fiber-tuned.log | 0 16 files changed, 381 insertions(+), 106 deletions(-) create mode 100644 site/data/results/fiber-tuned.json create mode 100644 site/static/logs/8gbit/512/fiber-tuned.log create mode 100644 site/static/logs/8gbit/512/fiber.log create mode 100644 site/static/logs/async-db/1024/fiber-tuned.log create mode 100644 site/static/logs/async/32000/fiber-tuned.log create mode 100644 site/static/logs/async/32000/fiber.log create mode 100644 site/static/logs/baseline/4096/fiber-tuned.log create mode 100644 site/static/logs/json-comp/16384/fiber-tuned.log create mode 100644 site/static/logs/json-comp/4096/fiber-tuned.log create mode 100644 site/static/logs/json-tls/4096/fiber-tuned.log create mode 100644 site/static/logs/latency-10k/1024/fiber-tuned.log create mode 100644 site/static/logs/latency-1m/1024/fiber-tuned.log create mode 100644 site/static/logs/limited-conn/4096/fiber-tuned.log create mode 100644 site/static/logs/pipelined/4096/fiber-tuned.log create mode 100644 site/static/logs/static-tls/1024/fiber-tuned.log diff --git a/site/data/results/fiber-tuned.json b/site/data/results/fiber-tuned.json new file mode 100644 index 000000000..6fbeb3e2b --- /dev/null +++ b/site/data/results/fiber-tuned.json @@ -0,0 +1,255 @@ +{ + "framework": "fiber-tuned", + "results": { + "8gbit-512": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 49337, + "avg_latency": "107.4us", + "p99_latency": "142.0us", + "cpu": "348.4%", + "memory": "840MiB", + "connections": 512, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "511.08MB/s", + "input_bw": "481.81MB/s", + "reconnects": 0, + "status_2xx": 246752, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0, + "cpu_usec": 17847807, + "cpu_per_req_us": 72.331, + "target_rate": 50000, + "rate_ratio": 0.9867, + "p99_9_latency": "4174.0us" + }, + "async-32000": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 1517624, + "avg_latency": "20.92ms", + "p99_latency": "30.70ms", + "cpu": "4238.2%", + "memory": "1.2GiB", + "connections": 32000, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "170.76MB/s", + "input_bw": "69.47MB/s", + "reconnects": 0, + "status_2xx": 15176243, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "async-db-1024": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 242289, + "avg_latency": "3.99ms", + "p99_latency": "31.10ms", + "cpu": "4625.2%", + "memory": "908MiB", + "connections": 1024, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "927.75MB/s", + "input_bw": "16.17MB/s", + "reconnects": 96864, + "status_2xx": 2422899, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "baseline-4096": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 2495624, + "avg_latency": "1.64ms", + "p99_latency": "3.64ms", + "cpu": "6398.6%", + "memory": "908MiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "280.78MB/s", + "input_bw": "192.78MB/s", + "reconnects": 0, + "status_2xx": 12478123, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "json-comp-16384": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 641662, + "avg_latency": "25.45ms", + "p99_latency": "51.70ms", + "cpu": "6345.8%", + "memory": "1.6GiB", + "connections": 16384, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "1.05GB/s", + "input_bw": "47.73MB/s", + "reconnects": 120097, + "status_2xx": 3208314, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "json-comp-4096": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 659490, + "avg_latency": "6.19ms", + "p99_latency": "23.20ms", + "cpu": "6303.9%", + "memory": "1.0GiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "1.08GB/s", + "input_bw": "49.06MB/s", + "reconnects": 130344, + "status_2xx": 3297450, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "json-tls-4096": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 1052542, + "avg_latency": "4.10ms", + "p99_latency": "108.37ms", + "cpu": "6259.1%", + "memory": "962MiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "3.59GB", + "reconnects": 0, + "status_2xx": 5367608, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "latency-10k-1024": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 9982, + "avg_latency": "76.8us", + "p99_latency": "106.0us", + "cpu": "41.1%", + "memory": "871MiB", + "connections": 1024, + "threads": 64, + "duration": "20s", + "pipeline": 1, + "bandwidth": "1.17MB/s", + "reconnects": 0, + "status_2xx": 199663, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0, + "cpu_usec": 8615714, + "cpu_per_req_us": 43.1513, + "target_rate": 10000, + "rate_ratio": 0.9982, + "p99_9_latency": "206.0us" + }, + "latency-1m-1024": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 998228, + "avg_latency": "122.1us", + "p99_latency": "249.0us", + "cpu": "4257.8%", + "memory": "868MiB", + "connections": 1024, + "threads": 64, + "duration": "20s", + "pipeline": 1, + "bandwidth": "116.79MB/s", + "reconnects": 0, + "status_2xx": 19967882, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0, + "cpu_usec": 748826463, + "cpu_per_req_us": 37.5015, + "target_rate": 1000000, + "rate_ratio": 0.9982, + "p99_9_latency": "4110.0us" + }, + "limited-conn-4096": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 2004197, + "avg_latency": "2.03ms", + "p99_latency": "7.05ms", + "cpu": "6271.2%", + "memory": "1.0GiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "225.48MB/s", + "input_bw": "154.82MB/s", + "reconnects": 1002026, + "status_2xx": 10020985, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "pipelined-4096": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 16909280, + "avg_latency": "3.88ms", + "p99_latency": "8.35ms", + "cpu": "6730.6%", + "memory": "895MiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 16, + "bandwidth": "1.86GB/s", + "reconnects": 0, + "status_2xx": 84546400, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "static-tls-1024": { + "framework": "fiber-tuned", + "language": "Go", + "rps": 546827, + "avg_latency": "1.92ms", + "p99_latency": "31.73ms", + "cpu": "6493.1%", + "memory": "824MiB", + "connections": 1024, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "8.36GB", + "reconnects": 0, + "status_2xx": 2788465, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + } + } +} diff --git a/site/data/results/fiber.json b/site/data/results/fiber.json index e7c2ebe86..b726bf2a9 100644 --- a/site/data/results/fiber.json +++ b/site/data/results/fiber.json @@ -1,87 +1,107 @@ { "framework": "fiber", "results": { - "async-db-1024": { + "8gbit-512": { "framework": "fiber", "language": "Go", - "rps": 98336, - "avg_latency": "10.18ms", - "p99_latency": "21.70ms", - "cpu": "3557.8%", - "memory": "162MiB", - "connections": 1024, + "rps": 49172, + "avg_latency": "107.7us", + "p99_latency": "147.0us", + "cpu": "340.4%", + "memory": "298MiB", + "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "378.58MB/s", - "input_bw": "6.56MB/s", - "reconnects": 39059, - "status_2xx": 983365, + "bandwidth": "509.37MB/s", + "input_bw": "480.20MB/s", + "reconnects": 0, + "status_2xx": 245927, "status_3xx": 0, "status_4xx": 0, - "status_5xx": 0 + "status_5xx": 0, + "cpu_usec": 18061682, + "cpu_per_req_us": 73.4433, + "target_rate": 50000, + "rate_ratio": 0.9834, + "p99_9_latency": "3311.0us" }, - "baseline-4096": { + "async-32000": { "framework": "fiber", "language": "Go", - "rps": 1071059, - "avg_latency": "3.84ms", - "p99_latency": "16.70ms", - "cpu": "4272.4%", - "memory": "221MiB", - "connections": 4096, + "rps": 1519706, + "avg_latency": "20.90ms", + "p99_latency": "30.60ms", + "cpu": "4245.8%", + "memory": "995MiB", + "connections": 32000, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "143.99MB/s", - "input_bw": "82.74MB/s", + "bandwidth": "170.99MB/s", + "input_bw": "69.57MB/s", "reconnects": 0, - "status_2xx": 5355298, + "status_2xx": 15197064, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 }, - "8gbit-512": { + "async-db-1024": { "framework": "fiber", "language": "Go", - "rps": 49320, - "avg_latency": "118.2us", - "p99_latency": "165.0us", - "cpu": "336.8%", - "memory": "83MiB", - "connections": 512, + "rps": 238426, + "avg_latency": "4.00ms", + "p99_latency": "27.10ms", + "cpu": "5456.0%", + "memory": "574MiB", + "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "512.04MB/s", - "input_bw": "481.64MB/s", + "bandwidth": "912.76MB/s", + "input_bw": "15.92MB/s", + "reconnects": 95369, + "status_2xx": 2384269, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 + }, + "baseline-4096": { + "framework": "fiber", + "language": "Go", + "rps": 2569980, + "avg_latency": "1.59ms", + "p99_latency": "3.60ms", + "cpu": "6401.9%", + "memory": "335MiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "289.13MB/s", + "input_bw": "198.52MB/s", "reconnects": 0, - "status_2xx": 246688, + "status_2xx": 12849904, "status_3xx": 0, "status_4xx": 0, - "status_5xx": 0, - "cpu_usec": 16520575, - "cpu_per_req_us": 66.9695, - "target_rate": 50000, - "rate_ratio": 0.9864, - "p99_9_latency": "7766.0us" + "status_5xx": 0 }, "json-comp-16384": { "framework": "fiber", "language": "Go", - "rps": 132907, - "avg_latency": "121.26ms", - "p99_latency": "613.80ms", - "cpu": "5848.2%", - "memory": "1.1GiB", + "rps": 179380, + "avg_latency": "89.94ms", + "p99_latency": "155.00ms", + "cpu": "6101.2%", + "memory": "1.5GiB", "connections": 16384, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "176.73MB/s", - "input_bw": "9.89MB/s", - "reconnects": 17899, - "status_2xx": 664538, + "bandwidth": "238.57MB/s", + "input_bw": "13.34MB/s", + "reconnects": 29951, + "status_2xx": 896904, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -89,19 +109,19 @@ "json-comp-4096": { "framework": "fiber", "language": "Go", - "rps": 128874, - "avg_latency": "31.62ms", - "p99_latency": "175.70ms", - "cpu": "5457.8%", - "memory": "405MiB", + "rps": 178046, + "avg_latency": "22.93ms", + "p99_latency": "59.70ms", + "cpu": "6197.0%", + "memory": "758MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "171.42MB/s", - "input_bw": "9.59MB/s", - "reconnects": 24138, - "status_2xx": 644373, + "bandwidth": "236.74MB/s", + "input_bw": "13.24MB/s", + "reconnects": 33815, + "status_2xx": 890231, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -109,18 +129,18 @@ "json-tls-4096": { "framework": "fiber", "language": "Go", - "rps": 503453, - "avg_latency": "66.53ms", - "p99_latency": "1.17s", - "cpu": "4881.3%", - "memory": "385MiB", + "rps": 848171, + "avg_latency": "5.03ms", + "p99_latency": "119.01ms", + "cpu": "6410.3%", + "memory": "653MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "1.72GB", + "bandwidth": "2.90GB", "reconnects": 0, - "status_2xx": 2567626, + "status_2xx": 4324961, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -129,66 +149,66 @@ "framework": "fiber", "language": "Go", "rps": 9983, - "avg_latency": "70.1us", - "p99_latency": "104.0us", - "cpu": "33.1%", - "memory": "82MiB", + "avg_latency": "72.4us", + "p99_latency": "105.0us", + "cpu": "40.8%", + "memory": "260MiB", "connections": 1024, "threads": 64, "duration": "20s", "pipeline": 1, - "bandwidth": "1.40MB/s", + "bandwidth": "1.17MB/s", "reconnects": 0, - "status_2xx": 199679, + "status_2xx": 199676, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "cpu_usec": 7292313, - "cpu_per_req_us": 36.5202, + "cpu_usec": 8542621, + "cpu_per_req_us": 42.7824, "target_rate": 10000, "rate_ratio": 0.9983, - "p99_9_latency": "209.0us" + "p99_9_latency": "248.0us" }, "latency-1m-1024": { "framework": "fiber", "language": "Go", - "rps": 997798, - "avg_latency": "2886.1us", - "p99_latency": "35440.0us", - "cpu": "3864.3%", - "memory": "105MiB", + "rps": 997607, + "avg_latency": "121.4us", + "p99_latency": "256.0us", + "cpu": "4251.9%", + "memory": "271MiB", "connections": 1024, "threads": 64, "duration": "20s", "pipeline": 1, - "bandwidth": "139.69MB/s", + "bandwidth": "116.72MB/s", "reconnects": 0, - "status_2xx": 19960725, + "status_2xx": 19955091, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "cpu_usec": 727028753, - "cpu_per_req_us": 36.423, + "cpu_usec": 746023273, + "cpu_per_req_us": 37.3851, "target_rate": 1000000, - "rate_ratio": 0.9978, - "p99_9_latency": "87712.0us" + "rate_ratio": 0.9976, + "p99_9_latency": "505.0us" }, "limited-conn-4096": { "framework": "fiber", "language": "Go", - "rps": 380194, - "avg_latency": "10.63ms", - "p99_latency": "111.80ms", - "cpu": "2746.2%", - "memory": "66MiB", + "rps": 2049170, + "avg_latency": "1.98ms", + "p99_latency": "6.82ms", + "cpu": "6465.0%", + "memory": "770MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "51.11MB/s", - "input_bw": "29.37MB/s", - "reconnects": 190105, - "status_2xx": 1900972, + "bandwidth": "230.54MB/s", + "input_bw": "158.29MB/s", + "reconnects": 1023947, + "status_2xx": 10245850, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -196,18 +216,18 @@ "pipelined-4096": { "framework": "fiber", "language": "Go", - "rps": 11882297, - "avg_latency": "5.53ms", - "p99_latency": "11.60ms", - "cpu": "6500.2%", - "memory": "131MiB", + "rps": 17195479, + "avg_latency": "3.81ms", + "p99_latency": "8.60ms", + "cpu": "6729.7%", + "memory": "338MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "1.56GB/s", + "bandwidth": "1.89GB/s", "reconnects": 0, - "status_2xx": 59411488, + "status_2xx": 85977399, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -215,18 +235,18 @@ "static-tls-1024": { "framework": "fiber", "language": "Go", - "rps": 84991, - "avg_latency": "62.36ms", - "p99_latency": "1.87s", - "cpu": "3539.4%", - "memory": "178MiB", + "rps": 557319, + "avg_latency": "1.88ms", + "p99_latency": "29.64ms", + "cpu": "6497.8%", + "memory": "514MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "5.04GB", + "bandwidth": "8.52GB", "reconnects": 0, - "status_2xx": 431994, + "status_2xx": 2842351, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/static/logs/8gbit/512/fiber-tuned.log b/site/static/logs/8gbit/512/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/8gbit/512/fiber.log b/site/static/logs/8gbit/512/fiber.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/async-db/1024/fiber-tuned.log b/site/static/logs/async-db/1024/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/async/32000/fiber-tuned.log b/site/static/logs/async/32000/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/async/32000/fiber.log b/site/static/logs/async/32000/fiber.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/baseline/4096/fiber-tuned.log b/site/static/logs/baseline/4096/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/16384/fiber-tuned.log b/site/static/logs/json-comp/16384/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/4096/fiber-tuned.log b/site/static/logs/json-comp/4096/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-tls/4096/fiber-tuned.log b/site/static/logs/json-tls/4096/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/latency-10k/1024/fiber-tuned.log b/site/static/logs/latency-10k/1024/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/latency-1m/1024/fiber-tuned.log b/site/static/logs/latency-1m/1024/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/limited-conn/4096/fiber-tuned.log b/site/static/logs/limited-conn/4096/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/pipelined/4096/fiber-tuned.log b/site/static/logs/pipelined/4096/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/static-tls/1024/fiber-tuned.log b/site/static/logs/static-tls/1024/fiber-tuned.log new file mode 100644 index 000000000..e69de29bb