diff --git a/scripts/docs-symbol-citations.test.ts b/scripts/docs-symbol-citations.test.ts index 8055550..93c857d 100644 --- a/scripts/docs-symbol-citations.test.ts +++ b/scripts/docs-symbol-citations.test.ts @@ -39,17 +39,12 @@ export function continuationLines(text: string): number[] { const ALLOW: Record = { "binding-and-validation.md": 1, "context-api-reference.md": 1, - "data-stores.md": 37, - "deployment.md": 34, "engines.md": 20, - "graceful-shutdown.md": 4, "middleware-auth.md": 1, "middleware-content.md": 25, - "middleware-routing-helpers.md": 37, "middleware.md": 21, "net-http-interop.md": 16, "observability.md": 31, - "performance.md": 36, "routing.md": 28, "sse.md": 26, "streaming.md": 15, diff --git a/src/content/docs/data-stores.md b/src/content/docs/data-stores.md index d77237f..3ef9967 100644 --- a/src/content/docs/data-stores.md +++ b/src/content/docs/data-stores.md @@ -22,7 +22,7 @@ interaction that makes colocation worthwhile. ## The `store.KV` abstraction -The contract lives in `middleware/store/kv.go`. `KV` is deliberately small — three +The contract lives in `celeris/middleware/store/kv.go`. `KV` is deliberately small — three methods, all `context`-aware and all operating on raw `[]byte`: ```go @@ -33,7 +33,7 @@ type KV interface { } ``` -The contract every backend must honor (`middleware/store/kv.go:24-40`): +The contract every backend must honor (`celeris/middleware/store/kv.go` (`KV`)): | Method | Contract | | ---------- | -------- | @@ -52,7 +52,7 @@ Some middleware need more than Get/Set/Delete. Rather than bloat the core interface, Celeris exposes optional **capability interfaces**. A backend implements whichever it can support atomically; middleware feature-detect them with a type assertion and fall back to emulation (or a documented no-op) when absent -(`middleware/store/kv.go:42-99`): +(`celeris/middleware/store/kv.go` (`GetAndDeleter`)): | Interface | Method(s) | What it adds | Who needs it | | ------------------- | --------- | ------------ | ------------ | @@ -70,7 +70,7 @@ capability table in the next section maps these requirements to concrete backend ### `NewMemoryKV` — the in-memory backend -`store.NewMemoryKV` (`middleware/store/memory.go:56`) returns a sharded, +`store.NewMemoryKV` (`celeris/middleware/store/memory.go` (`NewMemoryKV`)) returns a sharded, in-memory `*MemoryKV` that implements `KV` **plus every optional extension except `Scripter`** (`GetAndDeleter`, `Scanner`, `PrefixDeleter`, `SetNXer`, `Counter` — server-side Lua scripting is Redis-only). It is the @@ -83,7 +83,7 @@ kv := store.NewMemoryKV() // zero-config: NumCPU shards, 1-minute cleanup defer kv.Close() // stop the background expiry-reaper goroutine ``` -Tune it with `MemoryKVConfig` (`middleware/store/memory.go:15-29`): +Tune it with `MemoryKVConfig` (`celeris/middleware/store/memory.go` (`MemoryKVConfig`)): | Field | Type | Default | Meaning | | ----------------- | ----------------- | ------- | ------- | @@ -108,7 +108,7 @@ kv := store.NewMemoryKV(store.MemoryKVConfig{ ### `Prefixed` — sharing one backend across middleware -`store.Prefixed(inner, prefix)` (`middleware/store/prefix.go:16`) wraps any `KV` +`store.Prefixed(inner, prefix)` (`celeris/middleware/store/prefix.go` (`Prefixed`)) wraps any `KV` and transparently prepends `prefix` to every key. This lets several middleware share a single backend without key collisions: @@ -122,7 +122,7 @@ cacheStore := store.Prefixed(kv, "cache:") The wrapper implements all four extension interfaces — `GetAndDeleter`, `Scanner`, `PrefixDeleter`, and `SetNXer` — **unconditionally**, rewriting keys -and prefixes accordingly (`middleware/store/prefix.go:37-95`). When the inner +and prefixes accordingly (`celeris/middleware/store/prefix.go` (`prefixedKV.GetAndDelete`)). When the inner backend supports a capability natively, `Prefixed` delegates to it; otherwise it falls back (`GetAndDelete` → Get-then-Delete, `SetNX` → a *non-atomic* Get-then-Set, and `Scan`/`DeletePrefix` → no-ops when the inner is not a @@ -147,20 +147,20 @@ single-instance app needs zero store configuration: | Middleware | Config field | Type | Capabilities required | Default | | ------------- | ------------ | ---- | --------------------- | ------- | -| Session | `Store` | `store.KV` | `Scanner` for `Reset` (else no-op) | in-memory (`session.NewMemoryStore`) — `middleware/session/config.go:63-66,205-206` | -| Cache | `Store` | `store.KV` | `PrefixDeleter`/`Scanner` for prefix invalidation | in-memory — `middleware/cache/config.go:12-13,107-108` | -| CSRF | `Storage` | `store.KV` | `GetAndDeleter` for single-use tokens | **nil** — pure double-submit cookie mode (`middleware/csrf/config.go:73-82`) | -| Idempotency | `Store` | `idempotency.KVStore` (`store.KV` + `store.SetNXer`) | **`SetNXer` is mandatory** | in-memory — `middleware/idempotency/config.go:15-24` | -| SSE replay | `KV` | `store.KV` | `Counter` for cross-process IDs (else per-process) | none — you supply it — `middleware/sse/replay_kv.go:32-34` | +| Session | `Store` | `store.KV` | `Scanner` for `Reset` (else no-op) | in-memory (`session.NewMemoryStore`) — `celeris/middleware/session/config.go` (`Config.Store`, `applyDefaults`) | +| Cache | `Store` | `store.KV` | `PrefixDeleter`/`Scanner` for prefix invalidation | in-memory — `celeris/middleware/cache/config.go` (`Config.Store`, `applyDefaults`) | +| CSRF | `Storage` | `store.KV` | `GetAndDeleter` for single-use tokens | **nil** — pure double-submit cookie mode (`celeris/middleware/csrf/config.go` (`Config.Storage`)) | +| Idempotency | `Store` | `idempotency.KVStore` (`store.KV` + `store.SetNXer`) | **`SetNXer` is mandatory** | in-memory — `celeris/middleware/idempotency/config.go` (`KVStore`) | +| SSE replay | `KV` | `store.KV` | `Counter` for cross-process IDs (else per-process) | none — you supply it — `celeris/middleware/sse/replay_kv.go` (`KVReplayStoreConfig`) | > **CSRF is the exception to the in-memory default.** Leaving `Config.Storage` > unset runs CSRF in *pure double-submit cookie mode* (stateless — no server-side > store at all). Set `Storage` only when you want server-side token validation; > `SingleUseToken = true` **requires** it (the constructor panics otherwise — -> `middleware/csrf/config.go:198-199`). +> `celeris/middleware/csrf/config.go` (`Config.validate`)). > **Rate limiting is the exception.** The ratelimit middleware uses its own -> `ratelimit.Store` interface (`middleware/ratelimit/config.go:19`), **not** +> `ratelimit.Store` interface (`celeris/middleware/ratelimit/config.go` (`Store`)), **not** > `store.KV` — its operations are token-bucket-shaped, not key/value-shaped. > Redis-backed rate limiting ships as `middleware/ratelimit/redisstore`, which > implements `ratelimit.Store` via `EVALSHA`. Do not pass a `store.KV` where a @@ -170,7 +170,7 @@ Idempotency's requirement is worth calling out because it is a compile-time constraint, not a runtime fallback: ```go -// idempotency.KVStore = store.KV + store.SetNXer (middleware/idempotency/config.go:15-18) +// idempotency.KVStore = store.KV + store.SetNXer (celeris/middleware/idempotency/config.go, KVStore) type KVStore interface { store.KV store.SetNXer @@ -178,8 +178,8 @@ type KVStore interface { ``` `store.MemoryKV` satisfies this out of the box (it implements `SetNXer` — -`middleware/store/memory.go:220`). Because `Config.Store` is typed as `KVStore` -(`middleware/idempotency/config.go:24`), a backend without atomic SetNX *cannot* be +`celeris/middleware/store/memory.go` (`MemoryKV.SetNX`)). Because `Config.Store` is typed as `KVStore` +(`celeris/middleware/idempotency/config.go` (`Config.Store`)), a backend without atomic SetNX *cannot* be assigned to it — the constraint is enforced at compile time, not at runtime. There is simply nowhere to acquire the lock without `SetNX`. @@ -227,7 +227,7 @@ store := sessredis.New(rdb) // default key prefix "sess:" srv.Use(session.New(session.Config{Store: store})) ``` -`sessredis.New` (`middleware/session/redisstore/redisstore.go:74`) returns a +`sessredis.New` (`celeris/middleware/session/redisstore/redisstore.go` (`New`)) returns a `*Store` that implements `store.KV` **plus** `Scanner` and `PrefixDeleter` (via `SCAN` + `DEL`), so session `Reset` works across the cluster. The CSRF Redis store (`middleware/csrf/redisstore`) additionally implements `GetAndDeleter` via Redis @@ -236,9 +236,9 @@ srv.Use(session.New(session.Config{Store: store})) > **Tip: overlap the session store write with the next request.** When the > session store is a network backend (Redis), the post-handler `Set` sits on the > response critical path. Set `session.Config.WriteBehind = true` -> (`middleware/session/config.go:153`) to hand the encoded session to a single +> (`celeris/middleware/session/config.go` (`Config.WriteBehind`)) to hand the encoded session to a single > bounded background worker so the round trip overlaps subsequent work. Pair it -> with `session.NewWithCloser` (`middleware/session/session.go`) and drain the +> with `session.NewWithCloser` (`celeris/middleware/session/session.go`) and drain the > returned `io.Closer` on graceful shutdown — an acknowledged response no longer > guarantees the session is durable across an abrupt crash, so the drain flushes > queued writes. `Session.Destroy` stays synchronous regardless. @@ -269,8 +269,8 @@ CPU's cache and saves an epoll/io_uring syscall per round trip. In practice you pass the `*celeris.Server` itself to `WithEngine` — the server satisfies the small provider interface the drivers consume by exposing -`EventLoopProvider()` (`server.go:749`) and `AsyncHandlers()` -(`server.go:781`). The driver pulls the event loop from the first and its +`EventLoopProvider()` (`celeris/server.go` (`Server.EventLoopProvider`)) and `AsyncHandlers()` +(`celeris/server.go` (`Server.AsyncHandlers`)). The driver pulls the event loop from the first and its effective async state from the second. ### Redis @@ -291,11 +291,11 @@ defer rdb.Close() v, err := rdb.Get(ctx, "key") ``` -`redis.NewClient(addr, opts...)` (`driver/redis/client.go:20`) dials a lazy pool. +`redis.NewClient(addr, opts...)` (`celeris/driver/redis/client.go` (`NewClient`)) dials a lazy pool. The typed surface covers strings, hashes, lists, sets, sorted sets, key ops, pub/sub, scripting, pipelines, and `MULTI`/`EXEC` transactions; anything not typed is reachable via `rdb.Do(ctx, args...)`. Common options -(`driver/redis/options.go`): +(`celeris/driver/redis/options.go`): | Option | Effect | Default | | ------------------------------ | ------ | ------- | @@ -309,11 +309,11 @@ is reachable via `rdb.Do(ctx, args...)`. Common options > TLS (`rediss://`) is **not** supported — `NewClient` rejects the scheme with a > clear error. Deploy over a VPC, loopback, or a sidecar TLS terminator -> (`driver/redis/client.go:21-22`). +> (`celeris/driver/redis/client.go` (`NewClient`)). ### PostgreSQL -The Postgres driver offers two entry points (`driver/postgres/doc.go:6-37`): +The Postgres driver offers two entry points (`celeris/driver/postgres/doc.go`): **(a) `database/sql`** — portable, works with any ORM. Import for the side-effect registration, then `sql.Open` with the `celeris-postgres` driver name: @@ -331,7 +331,7 @@ db, err := sql.Open("celeris-postgres", In this mode `database/sql` owns the pool and the driver runs on a standalone event loop. To colocate a `database/sql` pool with your server, build a `*Connector` and rebind it with `WithEngine`, then `sql.OpenDB` -(`driver/postgres/connector.go:32-67`): +(`celeris/driver/postgres/connector.go` (`NewConnector`, `Connector.WithEngine`)): ```go import "github.com/goceleris/celeris/driver/postgres" @@ -346,7 +346,7 @@ defer db.Close() **(b) Direct `Pool`** — skips `database/sql` overhead and pins connections to worker-affinity slots. This is the peak-throughput path -(`driver/postgres/pool.go:126`): +(`celeris/driver/postgres/pool.go` (`Open`)): ```go pool, err := postgres.Open("postgres://app:pass@localhost/mydb?sslmode=disable", @@ -367,7 +367,7 @@ err = pool.QueryRow(ctx, The `Pool` API mirrors `database/sql`: `QueryContext`/`QueryRow` return `*Rows`/`*Row` with the familiar `Next()`/`Scan()`/`Err()` loop, `ExecContext` returns a `Result`, and `BeginTx` returns a `*Tx` with `Commit`/`Rollback` plus savepoints. `Pool` -options (`driver/postgres/pool.go:80-99`): +options (`celeris/driver/postgres/pool.go` (`WithMaxOpen`)): | Option | Effect | Default | | ---------------------------- | ------ | ------- | @@ -395,7 +395,7 @@ if err != nil { defer mc.Close() ``` -`memcached.NewClient(addr, opts...)` (`driver/memcached/client.go:33`) follows the +`memcached.NewClient(addr, opts...)` (`celeris/driver/memcached/client.go` (`NewClient`)) follows the same shape as the Redis client and accepts `WithEngine` to colocate. TLS is not supported. @@ -476,27 +476,27 @@ if err != nil { _ = n ``` -The verified driver signatures used above (`driver/redis/commands.go`, -`driver/postgres/pool.go`): +The verified driver signatures used above (`celeris/driver/redis/commands.go`, +`celeris/driver/postgres/pool.go`): | Call | Signature | Miss / not-found sentinel | | ---- | --------- | ------------------------- | -| `rdb.Get` | `Get(ctx, key string) (string, error)` | `redis.ErrNil` (`driver/redis/errors.go:10`) | +| `rdb.Get` | `Get(ctx, key string) (string, error)` | `redis.ErrNil` (`celeris/driver/redis/errors.go` (`ErrNil`)) | | `rdb.Set` | `Set(ctx, key string, value any, expiration time.Duration) error` | — | | `rdb.Del` | `Del(ctx, keys ...string) (int64, error)` | — (returns 0 if no key matched) | -| `pool.QueryRow(...).Scan` | `QueryRow(ctx, query string, args ...any) *Row`; `(*Row).Scan(dest ...any) error` | `sql.ErrNoRows` (`driver/postgres/pool.go:974`) | +| `pool.QueryRow(...).Scan` | `QueryRow(ctx, query string, args ...any) *Row`; `(*Row).Scan(dest ...any) error` | `sql.ErrNoRows` (`celeris/driver/postgres/pool.go` (`Row.Scan`)) | ### How colocation works When you pass `WithEngine(srv)`, the driver pulls the server's `EventLoopProvider` -(`server.go:749`) and registers its connection FDs on it. Concretely: +(`celeris/server.go` (`Server.EventLoopProvider`)) and registers its connection FDs on it. Concretely: - Without `WithEngine`, each driver resolves a **standalone** event loop, shared and reference-counted across all drivers that omit `WithEngine`. - With `WithEngine`, driver FDs land on the same worker goroutine as HTTP handlers. This saves one epoll/io_uring syscall per I/O and improves data locality — the Redis driver docs cite lower latency from better data locality for serial - queries (`driver/redis/doc.go:6-8`). + queries (`celeris/driver/redis/doc.go`). **Correctness is identical** with or without `WithEngine`; the difference is purely performance. @@ -516,14 +516,14 @@ at dial time and pick their I/O path accordingly: - **Async dispatch on** — the handler runs on a spawned, unlocked goroutine, so a blocking driver call parks that goroutine on Go's netpoll without stalling an I/O - worker. The drivers detect this (`server.go:781`, `AsyncHandlers`) and select + worker. The drivers detect this (`celeris/server.go` (`Server.AsyncHandlers`), `AsyncHandlers`) and select their direct net-conn fast path. - **Async dispatch off** — the handler runs inline on a `LockOSThread`'d worker. A blocking call there would stall the worker, so a different (mini-loop) path is used. The key detail: `Server.AsyncHandlers()` reports the **effective** state — it returns `true` if the server-level `Config.AsyncHandlers` is set **or** any route -opted in via `.Async()` / `.UsesDriver()` (`server.go:781-800`). So you have two +opted in via `.Async()` / `.UsesDriver()` (`celeris/server.go` (`Server.AsyncHandlers`)). So you have two ways to put a driver route on the fast path: ```go @@ -534,7 +534,7 @@ srv := celeris.New(celeris.Config{Addr: ":8080", AsyncHandlers: true}) srv.GET("/users/:id", getUser).UsesDriver() ``` -`.UsesDriver()` (`router.go:261-275`) is exactly equivalent to `.Async()` but reads +`.UsesDriver()` (`celeris/router.go` (`Route.UsesDriver`)) is exactly equivalent to `.Async()` but reads as intent at the call site — it marks a route whose handler performs a blocking backend round trip via a Celeris driver. It is the recommended way to flag driver routes: @@ -547,12 +547,12 @@ srv.GET("/users/:id", getUser).UsesDriver() // == .Async(), clearer intent > (`Config.AsyncHandlers = true` alone) only auto-promotes handlers slower than > ~300µs. A fast localhost driver call (sub-300µs) would otherwise keep blocking a > worker on every request. Mark such routes explicitly with `.UsesDriver()` -> (`router.go:261-275`). +> (`celeris/router.go` (`Route.UsesDriver`)). > **Ordering footgun.** `AsyncHandlers()` reflects routes registered *so far*. If > you rely on per-route `.UsesDriver()` (rather than the server-wide flag), open > your `WithEngine` drivers **after** registering those routes — or just set -> `Config.AsyncHandlers = true` to be order-independent (`server.go:788-790`). +> `Config.AsyncHandlers = true` to be order-independent (`celeris/server.go` (`Server.AsyncHandlers`)). ## Platform support @@ -585,7 +585,7 @@ on Linux. interface; use `middleware/ratelimit/redisstore` for distributed rate limiting. - **`WithEngine` without a native engine.** Colocation needs an event-loop engine. On the `std` (net/http) fallback engine, `EventLoopProvider()` returns `nil` - (`server.go:749-758`) — the driver transparently falls back to a standalone loop, + (`celeris/server.go` (`Server.EventLoopProvider`)) — the driver transparently falls back to a standalone loop, so you lose the colocation benefit (correctness is unaffected). - **Forgetting to mark fast driver routes.** A sub-300µs driver call on a route that isn't `.Async()`/`.UsesDriver()` (and without server-wide `AsyncHandlers`) diff --git a/src/content/docs/deployment.md b/src/content/docs/deployment.md index 6c6e98b..85656c0 100644 --- a/src/content/docs/deployment.md +++ b/src/content/docs/deployment.md @@ -18,7 +18,7 @@ Terminate TLS upstream and forward cleartext HTTP/1.1 or h2c to Celeris. ## TLS: terminate it upstream -Celeris exposes exactly three protocol modes (`celeris/config.go:12-22`), all of +Celeris exposes exactly three protocol modes (`celeris/config.go` (`Protocol`)), all of them cleartext: | `Config.Protocol` | Wire protocol | @@ -65,7 +65,7 @@ s := celeris.New(celeris.Config{ ``` How `Auto` handles the HTTP/1.1 `Upgrade: h2c` handshake is controlled by -`Config.EnableH2Upgrade` (`celeris/config.go:265-276`), a `*bool`: +`Config.EnableH2Upgrade` (`celeris/config.go` (`Config.EnableH2Upgrade`)), a `*bool`: | `EnableH2Upgrade` | Effect | | ------------------ | ---------------------------------------------------------------- | @@ -133,10 +133,10 @@ URL. Celeris gives you two complementary tools to fix this. ### `Config.TrustedProxies` — corrects `ClientIP()` Set `Config.TrustedProxies` to the CIDR ranges (or bare IPs) of your proxies -(`celeris/config.go:256-260`). When set, `c.ClientIP()` walks the +(`celeris/config.go` (`Config.TrustedProxies`)). When set, `c.ClientIP()` walks the `X-Forwarded-For` chain **right-to-left**, skipping hops inside a trusted network, and returns the first untrusted address — the real client -(`celeris/context_request.go:425-493`): +(`celeris/context_request.go` (`Context.ClientIP`)): ```go s := celeris.New(celeris.Config{ @@ -154,13 +154,13 @@ s.GET("/whoami", func(c *celeris.Context) error { Entries accept CIDR notation (`10.0.0.0/8`) or a bare IP (`10.0.0.1`, expanded to `/32` or `/128`). An invalid entry is a startup error from `Start` — -`celeris: invalid TrustedProxies entry: …` (`celeris/server.go:909-916`), so a typo +`celeris: invalid TrustedProxies entry: …` (`celeris/server.go` (`Server.doPrepare`)), so a typo fails loudly rather than silently mis-attributing traffic. > **Without `TrustedProxies`, `ClientIP()` falls back to legacy behaviour**: it > returns the *leftmost* `X-Forwarded-For` entry, which is attacker-controlled and > trivially spoofed. Always set `TrustedProxies` in production -> (`celeris/context_request.go:425-448`). +> (`celeris/context_request.go` (`Context.ClientIP`)). ### The `proxy` middleware — corrects `Scheme()` and `Host()` too @@ -186,7 +186,7 @@ s.GET("/", func(c *celeris.Context) error { ``` The middleware inspects forwarded headers **only when the immediate peer is inside -`Config.TrustedProxies`** (`celeris/middleware/proxy/proxy.go:54-65`). Its +`Config.TrustedProxies`** (`celeris/middleware/proxy/proxy.go` (`New`)). Its `proxy.Config` options (`celeris/middleware/proxy/config.go`): | Field | Type | Default | Purpose | @@ -201,7 +201,7 @@ The middleware inspects forwarded headers **only when the immediate peer is insi `X-Forwarded-For` is walked right-to-left and `X-Real-Ip` is validated; `X-Forwarded-Proto` only accepts `http`/`https`; `X-Forwarded-Host` is rejected if it contains `\r`, `\n`, `\x00`, `/`, `\`, `?`, `#`, or `@`, or exceeds 253 bytes — -all to block header injection (`celeris/middleware/proxy/proxy.go:97-117, 215-226`). +all to block header injection (`celeris/middleware/proxy/proxy.go` (`New`, `isValidHost`)). Custom single-value IP headers work too — add the provider's header to `TrustedHeaders`: @@ -251,13 +251,12 @@ s.Use(healthcheck.New()) ``` A healthy probe returns `200 {"status":"ok"}`; an unhealthy one returns -`503 {"status":"unavailable"}` (`celeris/middleware/healthcheck/healthcheck.go:13-16, -163-174`). For `HEAD` the body is omitted. +`503 {"status":"unavailable"}` (`celeris/middleware/healthcheck/healthcheck.go` (`jsonUnavailable`, `respond`)). For `HEAD` the body is omitted. ### Default paths and configuration The three probes map to Kubernetes' three probe types. Defaults -(`celeris/middleware/healthcheck/config.go:11-15`): +(`celeris/middleware/healthcheck/config.go` (`DefaultLivePath`)): | Probe | Default path | `Config` field | Kubernetes probe | Question it answers | | ----------- | ------------ | -------------- | ------------------ | -------------------------------------------- | @@ -266,7 +265,7 @@ The three probes map to Kubernetes' three probe types. Defaults | Startup | `/startupz` | `StartPath` | `startupProbe` | Has init finished? (gates the other probes) | Each path has a matching `Checker` — a `func(c *celeris.Context) bool` -(`celeris/middleware/healthcheck/config.go:17-19`). The defaults always return +(`celeris/middleware/healthcheck/config.go` (`Checker`)). The defaults always return `true`; supply your own to reflect real dependency health: ```go @@ -284,12 +283,12 @@ s.Use(healthcheck.New(healthcheck.Config{ ``` `CheckerTimeout` bounds each checker; on timeout the probe returns 503 -(`celeris/middleware/healthcheck/config.go:54-66`). For trivial checkers that +(`celeris/middleware/healthcheck/config.go` (`Config.CheckerTimeout`)). For trivial checkers that cannot block, set `CheckerTimeout: healthcheck.FastPathTimeout` to skip the -goroutine/channel machinery entirely (`config.go:69-75`). The default checkers are +goroutine/channel machinery entirely (`celeris/middleware/healthcheck/config.go` (`FastPathTimeout`)). The default checkers are already optimised this way automatically. -`Config` knobs (`celeris/middleware/healthcheck/config.go:21-67`): +`Config` knobs (`celeris/middleware/healthcheck/config.go` (`Config`)): | Field | Type | Default | Notes | | ---------------- | ---------------- | ------------- | ---------------------------------------------------------- | @@ -304,12 +303,12 @@ already optimised this way automatically. | `Skip` | `func(c) bool` | none | Dynamic bypass | The constants `healthcheck.DefaultLivePath`, `DefaultReadyPath`, and -`DefaultStartPath` (`config.go:10-15`) let you reference the defaults from your +`DefaultStartPath` (`celeris/middleware/healthcheck/config.go`) let you reference the defaults from your Kubernetes manifest generators without hard-coding strings. > **Validation panics at startup**, not at request time: two probes sharing a path, > or a path not starting with `/`, panics in `healthcheck.New` -> (`celeris/middleware/healthcheck/config.go:111-137`). Catch it in CI, not in prod. +> (`celeris/middleware/healthcheck/config.go` (`Config.validate`)). Catch it in CI, not in prod. ### Wiring to Kubernetes @@ -346,12 +345,12 @@ s := celeris.New(celeris.Config{Addr: ":8080"}) // 0.0.0.0:8080, not 127.0.0.1 ``` `Addr` follows Go's `net.Listen` syntax. `:0` binds an OS-assigned port — read it -back with `s.Addr()` after `Start` (`celeris/server.go:726-734`), handy in tests. +back with `s.Addr()` after `Start` (`celeris/server.go` (`Server.Addr`)), handy in tests. ### Workers and GOMAXPROCS `Config.Workers` sets the number of I/O worker goroutines and **defaults to -`GOMAXPROCS`** (`celeris/config.go:82-83`). In a container, `GOMAXPROCS` defaults to +`GOMAXPROCS`** (`celeris/config.go` (`Config.Workers`)). In a container, `GOMAXPROCS` defaults to the *node's* CPU count unless you constrain it, which over-subscribes a pod with a CPU limit. On Go 1.25+ the runtime reads the cgroup CPU quota automatically; otherwise set `GOMAXPROCS` to match the pod's CPU limit (or pin `Workers` @@ -373,7 +372,7 @@ runtime — so size it once to the CPUs the pod actually has. > One Linux-specific exception: when the adaptive engine starts on io_uring, it may > reduce the io_uring worker count at startup if `RLIMIT_MEMLOCK` cannot fund the -> requested rings (`celeris/internal/engine/iouring/ring.go:45-75`). That is a one-time memlock cap at start, not +> requested rings (`celeris/internal/engine/iouring/ring.go` (`capWorkersToMemlock`)). That is a one-time memlock cap at start, not > a runtime scaler — raise `memlock` (below) to fund the full count. ### Memory limits and peak RSS @@ -412,7 +411,7 @@ falls back to epoll. This is an optimisation, not a fix-or-fail: epoll is at throughput parity, so a container that can't use io_uring still runs at full speed. When io_uring setup is denied, Celeris does **not** crash — the probe's `io_uring_setup` call returns an error, the io_uring tier is left unselected, and the -adaptive engine runs on epoll (`celeris/internal/probe/probe.go:122-158`). Confirm which +adaptive engine runs on epoll (`celeris/internal/probe/probe.go` (`ProbeWith`)). Confirm which engine you actually got at runtime with `Server.EngineInfo()` (see [Engines](/docs/engines)). @@ -486,9 +485,9 @@ ulimits: Other io_uring prerequisites (checked by the startup io_uring probe): - **Kernel 5.10+** — Celeris's LTS-stable io_uring floor; older kernels fall through - to epoll (`celeris/internal/probe/probe.go:112-117`). + to epoll (`celeris/internal/probe/probe.go` (`ProbeWith`)). - **`CAP_SYS_NICE`** is consulted for SQPoll on some kernels - (`celeris/internal/probe/probe_linux.go:99-116`); not required for the basic io_uring path. + (`celeris/internal/probe/probe_linux.go` (`checkCapSysNiceLinux`)); not required for the basic io_uring path. You do not need to do anything special for epoll; it works on Linux 3.10+ out of the box. On macOS and Windows the engine is `std` (Go `net/http`). @@ -501,7 +500,7 @@ across workers — no userspace accept lock. This is internal and automatic; you not configure it. The one place it surfaces is zero-downtime restarts (below): when you hand a listener to `StartWithListener`, the native engines extract the address and rebind their own `SO_REUSEPORT` sockets to it, and **you must not `Accept` on or -close the passed listener afterward** (`celeris/server.go:1035-1066`). +close the passed listener afterward** (`celeris/server.go` (`Server.StartWithListener`)). A start that fails before its engine runs (a configuration error, or an engine that cannot be created) closes the listener before it returns the error, so a failed start leaves nothing for you to close. The exception is `ErrAlreadyStarted`: the server is already running, and the listener stays with the caller ([celeris#737](https://github.com/goceleris/celeris/issues/737), fixed by [celeris#747](https://github.com/goceleris/celeris/pull/747)). ### systemd unit @@ -552,7 +551,7 @@ if err := s.StartWithContext(ctx); err != nil { Neither `Shutdown` nor `PauseAccept` touches the readiness probe — the `healthcheck` middleware only ever returns what your `ReadyChecker` returns -(`celeris/middleware/healthcheck/healthcheck.go:83`). To stop the LB sending new +(`celeris/middleware/healthcheck/healthcheck.go` (`New`)). To stop the LB sending new traffic during a drain you have to flip readiness yourself, and before the drain begins: the `OnShutdown` hooks run only after the requests in flight have finished, or once the shutdown deadline has passed, on every engine since celeris v1.6.0 @@ -596,7 +595,7 @@ engine. (`atomic.Bool` is in the standard library's `sync/atomic`.) For true zero-downtime restarts on the same host, inherit the listening socket across the exec with `InheritListener` + `StartWithListener` -(`celeris/server.go:1148-1166`, `celeris/server.go:1035-1066`): +(`celeris/server.go` (`InheritListener`), `celeris/server.go` (`Server.StartWithListener`)): ```go ln, err := celeris.InheritListener("CELERIS_LISTENER_FD") @@ -635,13 +634,13 @@ The timeout and limit fields most relevant in production (full list in | Field | Default | Why it matters in prod | | -------------------- | ------- | ---------------------------------------------------------------- | -| `ReadHeaderTimeout` | `10s` | Slow-loris defence — drip-fed headers get killed fast (`celeris/config.go:94-104`) | -| `ReadTimeout` | `60s` | Caps total request read time (`celeris/config.go:91-93`) | -| `WriteTimeout` | `60s` | Caps response write time (`celeris/config.go:105-107`) | -| `IdleTimeout` | `600s` | Keep-alive idle cap; set below the LB's idle timeout (`celeris/config.go:108-110`) | -| `ShutdownTimeout` | `30s` | Drain budget on graceful shutdown (`celeris/config.go:111-115`) | +| `ReadHeaderTimeout` | `10s` | Slow-loris defence — drip-fed headers get killed fast (`celeris/config.go` (`Config.ReadHeaderTimeout`)) | +| `ReadTimeout` | `60s` | Caps total request read time (`celeris/config.go` (`Config.ReadTimeout`)) | +| `WriteTimeout` | `60s` | Caps response write time (`celeris/config.go` (`Config.WriteTimeout`)) | +| `IdleTimeout` | `600s` | Keep-alive idle cap; set below the LB's idle timeout (`celeris/config.go` (`Config.IdleTimeout`)) | +| `ShutdownTimeout` | `30s` | Drain budget on graceful shutdown (`celeris/config.go` (`Config.ShutdownTimeout`)) | | `MaxRequestBodySize` | `4MiB` | Reject oversized bodies; `-1` disables (was `100MB` before v1.6.0) | -| `MaxConns` | `0` | Per-worker connection cap; `0` = unlimited (`celeris/config.go:163-164`) | +| `MaxConns` | `0` | Per-worker connection cap; `0` = unlimited (`celeris/config.go` (`Config.MaxConns`)) | Set `IdleTimeout` *below* your load balancer's upstream idle timeout so Celeris closes idle keep-alives first, avoiding the race where the LB reuses a connection @@ -656,7 +655,7 @@ engine selection and the feature matrix, see [Engines](/docs/engines). ## Logging and observability in production Pass a structured `*slog.Logger` via `Config.Logger` (defaults to `slog.Default()`, -`celeris/config.go:262-263`); use a JSON handler so your log pipeline can parse it: +`celeris/config.go` (`Config.Logger`)); use a JSON handler so your log pipeline can parse it: ```go logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ @@ -667,7 +666,7 @@ s := celeris.New(celeris.Config{Addr: ":8080", Logger: logger}) Built-in metrics are on by default; read a snapshot from the collector for a `/metrics`-style endpoint, or disable with `Config.DisableMetrics` -(`celeris/config.go:178-181`): +(`celeris/config.go` (`Config.DisableMetrics`)): ```go snap := s.Collector().Snapshot() // requests, errors, latency, active conns, CPU diff --git a/src/content/docs/graceful-shutdown.md b/src/content/docs/graceful-shutdown.md index ccfb909..8fbaa59 100644 --- a/src/content/docs/graceful-shutdown.md +++ b/src/content/docs/graceful-shutdown.md @@ -442,7 +442,7 @@ Plain `session.New` gives you no such handle, so a graceful stop cannot drain th queue and the final updates of in-flight requests may be lost — prefer `NewWithCloser`/`NewHandler` whenever `WriteBehind` is on. See [Middleware](/docs/middleware) for the full session configuration surface. Source: -`celeris/middleware/session/writebehind.go`, `celeris/middleware/session/session.go:319,395`. +`celeris/middleware/session/writebehind.go`, `celeris/middleware/session/session.go` (`Handler.Close`, `NewWithCloser`). ## Pause and resume accept @@ -476,7 +476,7 @@ _ = s.ResumeAccept() started yet (no engine is installed). Always check the error and have a fallback (a full `Shutdown`) for portability. Source: `celeris/server.go` (`PauseAccept`, `ResumeAccept`), -`celeris/errors.go:29-31`, `celeris/internal/engine/engine.go:38-62`. See +`celeris/errors.go` (`ErrAcceptControlNotSupported`), `celeris/internal/engine/engine.go` (`AcceptController`). See [Engines](/docs/engines) for which engine runs where. > Pause/resume is for *temporary* quiescing. It does not drain — in flight requests @@ -543,7 +543,7 @@ In both cases the contract is the same: after calling `StartWithListener`, the listener belongs to Celeris. That holds when the start fails, too: if the server cannot start before its engine runs (a configuration error, or an engine that cannot be created), Celeris closes the listener before it returns the error, since celeris v1.6.0 -([celeris#737](https://github.com/goceleris/celeris/issues/737)). The one exception is +([celeris#737](https://github.com/goceleris/celeris/issues/737), fixed by [celeris#747](https://github.com/goceleris/celeris/pull/747)). The one exception is `ErrAlreadyStarted`: the server is already running, so the listener you passed stays yours. Source: `celeris/server.go` (`StartWithListener`, `prepareWithListener`). @@ -566,7 +566,7 @@ To avoid it when using socket handoff, **leave `Config.Addr` empty** (or set it value that matches the listener). Two cases are deliberately *allowed* and do not error: the default `":8080"`, and any `":0"` (pick-any-port), since delegating port selection to the pre-bound listener is a common, intentional pattern. Source: -`celeris/internal/resource/config.go:210-224`. +`celeris/internal/resource/config.go` (`Config.Validate`). ```go // ✅ No Addr → no ambiguity. The listener decides the bind address. diff --git a/src/content/docs/middleware-routing-helpers.md b/src/content/docs/middleware-routing-helpers.md index 1f30b83..dd0ca35 100644 --- a/src/content/docs/middleware-routing-helpers.md +++ b/src/content/docs/middleware-routing-helpers.md @@ -24,13 +24,13 @@ everything on this page. | Hook | Runs | Can mutate before routing? | Source | | -------------------- | --------------------------------- | ----------------------------------------- | ----------------------- | -| `Server.Pre(mw...)` | **before** route lookup | yes — method, path, scheme, host | `celeris/server.go:211` | -| `Server.Use(mw...)` | after lookup, before the handler | no — the route is already chosen | `celeris/server.go:196` | +| `Server.Pre(mw...)` | **before** route lookup | yes — method, path, scheme, host | `celeris/server.go` (`Server.Pre`) | +| `Server.Use(mw...)` | after lookup, before the handler | no — the route is already chosen | `celeris/server.go` (`Server.Use`) | `Server.Pre` registers **pre-routing** middleware. It executes before the router resolves the handler chain, so a `Pre` handler may rewrite the request method or path and the router will then match against the *modified* values -(`celeris/server.go:204-214`). This is exactly what `redirect`, `rewrite`, and +(`celeris/server.go` (`Server.Pre`)). This is exactly what `redirect`, `rewrite`, and `methodoverride` need: they reshape the request and let routing happen afterwards. @@ -50,10 +50,10 @@ The mutators these middleware reach for are all on `*Context`: | Method | Effect | Source | | -------------------- | ----------------------------------------------- | ------------------------------ | -| `SetMethod(m)` | overrides the HTTP method seen by the router | `celeris/context_request.go:27`| -| `SetPath(p)` | overrides the request path before route lookup | `celeris/context_request.go:34`| -| `SetScheme(scheme)` | overrides `Scheme()` (e.g. from a proxy header) | `celeris/context_request.go:420`| -| `SetHost(host)` | overrides `Host()` | `celeris/context_request.go:681`| +| `SetMethod(m)` | overrides the HTTP method seen by the router | `celeris/context_request.go` (`Context.SetMethod`)| +| `SetPath(p)` | overrides the request path before route lookup | `celeris/context_request.go` (`Context.SetPath`)| +| `SetScheme(scheme)` | overrides `Scheme()` (e.g. from a proxy header) | `celeris/context_request.go` (`Context.SetScheme`)| +| `SetHost(host)` | overrides `Host()` | `celeris/context_request.go` (`Context.SetHost`)| ### The short-circuit contract @@ -63,7 +63,7 @@ A `Pre` handler has a binary choice on every request: handler) proceeds. It may have mutated the method/path first. - **Short-circuit** — write a response (e.g. `c.Redirect(...)`) and return **without** calling `c.Next()`. No routing occurs and the request is - considered handled (`celeris/server.go:207-209`). + considered handled (`celeris/server.go` (`Server.Pre`)). > The rule of thumb: **if a `Pre` handler writes to the response, it must not > call `Next`.** The redirect middleware follows this exactly — when it issues a @@ -102,10 +102,10 @@ otherwise they pass through. Source: `celeris/middleware/redirect/redirect.go`. | `HTTPSNonWWWRedirect` | not (`https` **and** non-`www.`) | `https://` host w/o `www.` (single) | Every constructor **preserves the original query string** (via -`buildRedirectURL`, `celeris/middleware/redirect/redirect.go:30-36`) and skips +`buildRedirectURL`, `celeris/middleware/redirect/redirect.go` (`buildRedirectURL`)) and skips the request — passing through to `c.Next()` — when `Host()` returns `""`, to avoid generating a malformed redirect URL (e.g. -`celeris/middleware/redirect/redirect.go:57-60`). +`celeris/middleware/redirect/redirect.go` (`HTTPSRedirect`)). ### In-place rewrite variants @@ -120,7 +120,7 @@ handler simply sees the canonical path: The `*Rewrite` variants accept a `Config` for skip logic (`Skip`/`SkipPaths`) but never act on `Code`, since they don't send a redirect -(`celeris/middleware/redirect/redirect.go:250-290`). They still validate the +(`celeris/middleware/redirect/redirect.go` (`TrailingSlashRewrite`)). They still validate the config, so an out-of-range `Code` panics exactly as it would for a redirect constructor — just leave `Code` unset. @@ -159,7 +159,7 @@ An out-of-range `Code` panics at construction — a deliberate fail-fast: redirect: Code must be a redirect status (301, 302, 303, 307, 308), got 404 ``` -Source: `celeris/middleware/redirect/config.go:33-40`. +Source: `celeris/middleware/redirect/config.go` (`Config.validate`). ### A complete redirect example @@ -186,7 +186,7 @@ Conflicting redirects loop forever. Two combinations to never install together: For "HTTPS *and* a www change", prefer the combined `HTTPSWWWRedirect` / `HTTPSNonWWWRedirect` constructors over chaining `HTTPSRedirect` with `WWWRedirect`: the combined form does it in a single hop and sidesteps the -double-redirect (`celeris/middleware/redirect/redirect.go:180-248`). +double-redirect (`celeris/middleware/redirect/redirect.go` (`HTTPSWWWRedirect`)). ## rewrite — regex path rewriting @@ -194,7 +194,7 @@ When the trailing-slash and www helpers aren't enough, the `rewrite` package (`github.com/goceleris/celeris/middleware/rewrite`) matches the request path against an ordered list of regular-expression rules. Its single constructor is `New`, and patterns are **compiled once at init** with `regexp.MustCompile` -(`celeris/middleware/rewrite/rewrite.go:24-48`). +(`celeris/middleware/rewrite/rewrite.go` (`New`)). ```go import "github.com/goceleris/celeris/middleware/rewrite" @@ -218,7 +218,7 @@ type Rule struct { } ``` -Source: `celeris/middleware/rewrite/config.go:9-27`. +Source: `celeris/middleware/rewrite/config.go` (`Rule`). | Field | Behaviour | | -------------- | ----------------------------------------------------------------------- | @@ -229,7 +229,7 @@ Source: `celeris/middleware/rewrite/config.go:9-27`. | `Host` | Exact `Host` header match. Empty matches every host. | **First match wins.** Rules are evaluated in the order you list them; the first -regex that matches stops the search (`celeris/middleware/rewrite/rewrite.go:61-88`). +regex that matches stops the search (`celeris/middleware/rewrite/rewrite.go` (`New`)). ### Silent rewrite vs redirect @@ -241,7 +241,7 @@ happens when a rule matches: handler sees the rewritten path. - **`301/302/303/307/308` — redirect.** The middleware builds `scheme://host` + the rewritten path (plus `?query` when present) and sends a - `c.Redirect`, short-circuiting (`celeris/middleware/rewrite/rewrite.go:77-83`). + `c.Redirect`, short-circuiting (`celeris/middleware/rewrite/rewrite.go` (`New`)). ```go type Config struct { @@ -252,7 +252,7 @@ type Config struct { } ``` -Source: `celeris/middleware/rewrite/config.go:29-45`. +Source: `celeris/middleware/rewrite/config.go` (`Config`). ### A complete rewrite example @@ -290,7 +290,7 @@ s.Pre(rewrite.New(rewrite.Config{ - a `RedirectCode` (config or rule) is non-zero and not a valid redirect status - a `Pattern` is not a valid regex (surfaced by `regexp.MustCompile`) -Source: `celeris/middleware/rewrite/config.go:55-79`. +Source: `celeris/middleware/rewrite/config.go` (`Config.validate`). ## methodoverride — tunnelling PUT/PATCH/DELETE through POST @@ -303,7 +303,7 @@ form field or header. The middleware reads that value and calls `SetMethod` > **Install with `Server.Pre`, never `Server.Use`.** With `Use`, the router has > already matched on the original `POST`, so the override has no effect on > routing — and it does so silently. The package documents this explicitly -> (`celeris/middleware/methodoverride/doc.go:8-13`). +> (`celeris/middleware/methodoverride/doc.go`). ```go import "github.com/goceleris/celeris/middleware/methodoverride" @@ -317,7 +317,7 @@ s.DELETE("/users/:id", deleteUser) ### How a request is rewritten -On each request the middleware (`celeris/middleware/methodoverride/methodoverride.go:34-58`): +On each request the middleware (`celeris/middleware/methodoverride/methodoverride.go` (`New`)): 1. Skips if `Skip`/`SkipPaths` says so. 2. Checks the original method is in `AllowedMethods` (default: just `POST`). @@ -338,7 +338,7 @@ type Config struct { } ``` -Source: `celeris/middleware/methodoverride/config.go:17-40`. +Source: `celeris/middleware/methodoverride/config.go` (`Config`). | Field | Default | Notes | | ---------------- | ------------------------------- | -------------------------------------------------- | @@ -349,14 +349,14 @@ Source: `celeris/middleware/methodoverride/config.go:17-40`. `AllowedMethods` and `TargetMethods` must not contain empty/whitespace-only strings — the middleware panics at init if they do -(`celeris/middleware/methodoverride/config.go:61-72`). +(`celeris/middleware/methodoverride/config.go` (`Config.validate`)). ### Getters: where the override comes from The default getter checks the **form field `_method`** first, then the header **`X-HTTP-Method-Override`** — that order lets plain HTML forms (which can't set custom headers) drive the override with no JavaScript -(`celeris/middleware/methodoverride/config.go:79-87`). The package also exports +(`celeris/middleware/methodoverride/config.go` (`defaultGetter`)). The package also exports the constants `methodoverride.DefaultFormField` (`"_method"`) and `methodoverride.DefaultHeader` (`"X-HTTP-Method-Override"`). @@ -370,7 +370,7 @@ Swap the source with one of the getter factories: | `FormThenHeaderGetter(field, header)` | the named form field, then the named header | | `QueryGetter(param)` | the named **query parameter** — see warning | -Source: `celeris/middleware/methodoverride/config.go:89-126`. +Source: `celeris/middleware/methodoverride/config.go` (`HeaderGetter`). ```go // API clients send the method in a header only. @@ -383,7 +383,7 @@ s.Pre(methodoverride.New(methodoverride.Config{ > images — `` can trigger a destructive > override across sites. The package provides it for parity with other > frameworks but documents the risk explicitly; prefer `HeaderGetter` for API -> clients (`celeris/middleware/methodoverride/config.go:118-126`). +> clients (`celeris/middleware/methodoverride/config.go` (`QueryGetter`)). ### Interaction with CSRF @@ -391,7 +391,7 @@ Method override changes the method *before* CSRF middleware runs. Make sure the overridden targets (`PUT`, `DELETE`, `PATCH`) are **not** in your CSRF middleware's safe-methods list, or the protection is bypassed (the package warns about CSRF risk for query-based overrides at -`celeris/middleware/methodoverride/doc.go:17`). See [Security middleware](/docs/middleware-security). +`celeris/middleware/methodoverride/doc.go`). See [Security middleware](/docs/middleware-security). ## healthcheck — Kubernetes-style probes @@ -399,13 +399,13 @@ The `healthcheck` package (`github.com/goceleris/celeris/middleware/healthcheck`) answers liveness, readiness, and startup probes. It intercepts **`GET`/`HEAD`** requests to the configured probe paths and returns a small JSON status, passing every other -request straight through (`celeris/middleware/healthcheck/healthcheck.go:66-91`). +request straight through (`celeris/middleware/healthcheck/healthcheck.go` (`New`)). > Unlike the other middleware on this page, install `healthcheck` with > **`Server.Use`** (it is route middleware, not pre-routing), and install it > **first** so probes are answered before any heavier middleware runs. The > package's own examples use `server.Use(healthcheck.New())` -> (`celeris/middleware/healthcheck/example_test.go:13`). +> (`celeris/middleware/healthcheck/example_test.go` (`ExampleNew`)). ```go import "github.com/goceleris/celeris/middleware/healthcheck" @@ -425,15 +425,15 @@ s.GET("/users/:id", showUser) | Startup | `StartPath` | `/startupz` | `healthcheck.DefaultStartPath` | Set any path to the **empty string `""` to disable that probe** -(`celeris/middleware/healthcheck/config.go:94`). Enabled paths must start with +(`celeris/middleware/healthcheck/config.go` (`applyDefaults`)). Enabled paths must start with `/`, and no two enabled probes may share a path — both are init-time panics -(`celeris/middleware/healthcheck/config.go:111-137`). +(`celeris/middleware/healthcheck/config.go` (`Config.validate`)). ### Checkers Each probe has a `Checker func(c *celeris.Context) bool`. Return `true` for healthy (`200`), `false` for unhealthy (`503`). All three default to -always-`true` (`celeris/middleware/healthcheck/config.go:42-52`). +always-`true` (`celeris/middleware/healthcheck/config.go` (`Config`)). ```go type Config struct { @@ -449,7 +449,7 @@ type Config struct { } ``` -Source: `celeris/middleware/healthcheck/config.go:21-67`. +Source: `celeris/middleware/healthcheck/config.go` (`Config`). ```go s.Use(healthcheck.New(healthcheck.Config{ @@ -476,9 +476,9 @@ The `*Context` is passed in so a checker can honour the request deadline (e.g. | positive duration | Run the checker with that deadline; if it doesn't return, respond `503`. | | `healthcheck.FastPathTimeout` | Run the checker **synchronously inline** — no goroutine/channel/context overhead. Use only for trivial checkers that cannot block. | -Source: `celeris/middleware/healthcheck/config.go:54-75`. A checker that +Source: `celeris/middleware/healthcheck/config.go` (`Config.CheckerTimeout`). A checker that **panics** is recovered and treated as a failure (`503`), so a bad probe never -crashes the server (`celeris/middleware/healthcheck/healthcheck.go:154-161`). +crashes the server (`celeris/middleware/healthcheck/healthcheck.go` (`safeCheck`)). ```go s.Use(healthcheck.New(healthcheck.Config{ @@ -490,7 +490,7 @@ s.Use(healthcheck.New(healthcheck.Config{ > Trivial built-in (always-true) checkers are automatically run on the fast > path regardless of `CheckerTimeout`, since the timeout machinery would be pure > overhead for a check that can't block -> (`celeris/middleware/healthcheck/healthcheck.go:32-61`). You only need to set +> (`celeris/middleware/healthcheck/healthcheck.go` (`New`)). You only need to set > `FastPathTimeout` yourself when you supply your own cheap, non-blocking > checker. @@ -502,7 +502,7 @@ s.Use(healthcheck.New(healthcheck.Config{ | `HEAD /readyz` | `200`, empty body | `503`, empty body | Content type is `application/json`. `HEAD` returns the status code with no body -(`celeris/middleware/healthcheck/healthcheck.go:163-174`). +(`celeris/middleware/healthcheck/healthcheck.go` (`respond`)). ### Wiring to Kubernetes probes diff --git a/src/content/docs/performance.md b/src/content/docs/performance.md index 20dfa37..d13b55f 100644 --- a/src/content/docs/performance.md +++ b/src/content/docs/performance.md @@ -40,7 +40,7 @@ never stash a `*celeris.Context` in a struct, a closure, or a goroutine. From body as a slice over the engine's buffer — fast, zero-copy, but invalid the instant the handler returns and **must not be modified**. If the bytes have to outlive the request (you pass them to a goroutine, an async pipeline, or a log sink), take a -copy with `c.BodyCopy()` (`celeris/context_request.go:291-302`): +copy with `c.BodyCopy()` (`celeris/context_request.go` (`Context.BodyCopy`)): ```go s.POST("/ingest", func(c *celeris.Context) error { @@ -61,7 +61,7 @@ deliberately, not by reflex. **Prefer `FullPath()` for metric and log labels.** `c.FullPath()` returns the **matched route pattern** (`/users/:id`), not the concrete request path -(`/users/42`) — see `celeris/context_request.go:36-44`. Using the raw path as a metric +(`/users/42`) — see `celeris/context_request.go` (`Context.FullPath`). Using the raw path as a metric label explodes cardinality (one time series per id); the pattern keeps it bounded: ```go @@ -81,14 +81,14 @@ middleware). **Let the JSON fast path do its job.** `c.JSON` has a reflection-free encoder for small maps and primitive types that emits byte-identical output to the standard library while skipping the reflection machinery -(`celeris/context_response.go:84-132`). You don't enable anything — it's automatic. +(`celeris/context_response.go` (`Context.JSON`, `jsonSafeValue`)). You don't enable anything — it's automatic. The practical takeaway is that returning a small `map[string]string` or a flat struct from a handler is genuinely cheap; you do not need to hand-roll byte buffers to be fast. **Lowercase your header keys when you set them.** Programmatic header writes hit an inline fast path when the key is already lowercase -(`celeris/context_response.go:637-666`). Writing `c.SetHeader("content-type", …)` +(`celeris/context_response.go` (`Context.SetHeader`)). Writing `c.SetHeader("content-type", …)` avoids a normalization step that `c.SetHeader("Content-Type", …)` would incur. ### Common pitfalls @@ -116,7 +116,7 @@ so the worker returns to `epoll_wait` / `io_uring_enter` while the handler waits ### The three levers The dispatch mode is resolved **route > group > server default**, where the server -default is `Config.AsyncHandlers` (`celeris/config.go:183-239`, `celeris/router.go:222-319`): +default is `Config.AsyncHandlers` (`celeris/config.go` (`Config.AsyncHandlers`), `celeris/router.go` (`Route.Async`)): | Lever | Where | Effect | | --- | --- | --- | @@ -138,18 +138,18 @@ api.GET("/cached", cachedHandler).Sync() // …opt this one back to inline `.UsesDriver()` is exactly `.Async()`, but it documents intent at the call site: *this route calls a Celeris postgres/redis/memcached driver* -(`celeris/router.go:261-275`). On the `Std` (net/http) engine the per-route flag is +(`celeris/router.go` (`Route.UsesDriver`)). On the `Std` (net/http) engine the per-route flag is a no-op — net/http already runs a goroutine per request. > **Safety.** Never call `.Sync()` (or `.Async(false)`) on a handler that hijacks > or detaches the connection — WebSocket upgrades and SSE streams run async by -> construction and the flag cannot downgrade them (`celeris/router.go:245-259`). +> construction and the flag cannot downgrade them (`celeris/router.go` (`Route.Sync`)). ### The ~3–5% async overhead, and when to pay it Async dispatch costs a goroutine spawn per request (~100 ns) plus scheduler overhead. On a pure-CPU static-response benchmark that measures as a **~3–5% -regression** (`celeris/config.go:196-199`). So the rule is simple: +regression** (`celeris/config.go` (`Config.AsyncHandlers`)). So the rule is simple: - **CPU-only, latency-critical routes** → keep them inline (`Sync`, the default). - **Anything that touches a DB, cache, or upstream service** → mark it async, so @@ -157,38 +157,38 @@ regression** (`celeris/config.go:196-199`). So the rule is simple: When `AsyncHandlers` is `true`, the per-worker serialization ceiling (`NumWorkers × 1/RTT`) is replaced by goroutine-per-connection parallelism that -matches net/http's concurrency model (`celeris/config.go:183-189`). +matches net/http's concurrency model (`celeris/config.go` (`Config.AsyncHandlers`)). ### Adaptive auto-promotion (and why fast driver calls need `UsesDriver`) Setting `Config.AsyncHandlers = true` also turns on an **adaptive safety net**: any *unmarked* handler that runs slower than **~300 µs** is auto-promoted to the goroutine path, while routes that stay fast settle back to a zero-cost inline path -after a short learning phase (`celeris/config.go:230-236`, -`celeris/router.go:222-243`). +after a short learning phase (`celeris/config.go` (`Config.AsyncHandlers`), +`celeris/router.go` (`Route.Async`)). This is why a **fast localhost driver call needs an explicit `.UsesDriver()` / `.Async()`**: a colocated Redis or Postgres round-trip on the loopback can complete in *under* 300 µs, so the adaptive net never promotes it — and it would block a worker on every request. Mark driver routes explicitly and you guarantee they're dispatched off the worker regardless of how fast the backend answers -(`celeris/router.go:261-275`). +(`celeris/router.go` (`Route.UsesDriver`)). > **Driver fast path.** Celeris drivers opened `WithEngine(srv)` pick their > netpoll-park fast path from the server's *effective* async state — true when > `AsyncHandlers` is set **or** any route is `.Async()`. If you keep > `AsyncHandlers` false and rely on per-route marks, **open the driver after those > routes are registered** (the effective state is read at driver construction); -> otherwise set `AsyncHandlers = true` (`celeris/config.go:224-236`). See +> otherwise set `AsyncHandlers = true` (`celeris/config.go` (`Config.AsyncHandlers`)). See > [Stores and database drivers](/docs/data-stores). ### Watching the handoff: `AsyncPromotedConns` The engine exposes how often the inline → goroutine handoff actually fires. `EngineMetrics.AsyncPromotedConns` is the cumulative count of connections promoted -to the per-conn dispatch goroutine (`celeris/observe/engine_metrics.go:115-122`), and +to the per-conn dispatch goroutine (`celeris/observe/engine_metrics.go` (`EngineMetrics.AsyncPromotedConns`)), and `EngineMetrics.AsyncRoutes` reports how many routes are registered async -(`celeris/observe/engine_metrics.go:108-114`). Read them off the running server: +(`celeris/observe/engine_metrics.go` (`EngineMetrics.AsyncRoutes`)). Read them off the running server: ```go info := s.EngineInfo() // nil before Start @@ -211,7 +211,7 @@ For the full dispatch model see [Engines and the I/O model](/docs/engines) and On Linux you choose the I/O engine via `Config.Engine`; the default is **Adaptive** (`Std` on non-Linux). Adaptive starts on epoll — best for ramp-from-zero, low-concurrency, and latency-sensitive traffic — and promotes individual -connections to io_uring under sustained high load (`celeris/config.go:45-62`). +connections to io_uring under sustained high load (`celeris/config.go` (`WorkloadHint`)). ### Letting Adaptive decide vs forcing an engine @@ -226,7 +226,7 @@ As of v1.5.6 Adaptive **transplants** established keep-alive connections between epoll and io_uring on a switch (both directions), so the *starting* engine no longer fixes keep-alive throughput. Concurrency is still unknowable at bind time, though, so `Config.WorkloadHint` is the lever that biases Adaptive's start choice -without hard-pinning the engine (`celeris/config.go:45-62`): +without hard-pinning the engine (`celeris/config.go` (`WorkloadHint`)): ```go s := celeris.New(celeris.Config{ @@ -271,7 +271,7 @@ A few tuning notes grounded in the engine signals: `MaxConns: 10_000` your ceiling is ~80k connections. It's an admission bound, not a tuning knob — use it to fail fast rather than thrash when a flood arrives. - **The bytes-per-request signal.** The engine tracks `BytesRead` and - `BytesWritten` alongside `RequestCount` (`celeris/observe/engine_metrics.go:141-149`). + `BytesWritten` alongside `RequestCount` (`celeris/observe/engine_metrics.go` (`EngineMetrics`)). Large average payloads (link-bound workloads) make epoll and io_uring tie, so the adaptive controller *suppresses* io_uring selection for them — there's nothing for you to set, but it explains why a large-response service may stay on epoll under @@ -287,7 +287,7 @@ that high-water mark back to the OS. `Config.MemoryLimitBytes` is an **optional soft heap ceiling** (applied via `runtime/debug.SetMemoryLimit` at `Start`) that makes the GC collect before the heap balloons during that ramp, trading a few extra ramp-phase GC cycles for a lower peak. Steady RSS sits far below the limit, -so steady-state throughput is unaffected (`celeris/config.go:166-176`): +so steady-state throughput is unaffected (`celeris/config.go` (`Config.MemoryLimitBytes`)): ```go cfg := celeris.Config{Addr: ":8080", Workers: 8} @@ -296,7 +296,7 @@ s := celeris.New(cfg) ``` `celeris.DeriveMemoryLimit(workers)` returns `max(256 MiB, workers × 32 MiB)` -(`celeris/config.go:63-70`) — sized **high** on purpose: the goal is to clip the +(`celeris/config.go` (`DeriveMemoryLimit`)) — sized **high** on purpose: the goal is to clip the ramp spike, not run the heap tight. Two caveats: `0` (the default) means celeris **does not touch** the process GC, so embedders keep full control; and `SetMemoryLimit` is **process-global**, so only set this when celeris owns the @@ -309,7 +309,7 @@ See [Configuration reference](/docs/configuration) for the full field list and Keep-alive is on by default — reusing a TCP connection across requests is the single biggest throughput win on a benchmark and a real workload alike. The -relevant `Config` fields (`celeris/config.go:91-136`): +relevant `Config` fields (`celeris/config.go` (`Config`)): | Field | Default | Notes | | --- | --- | --- | @@ -397,7 +397,7 @@ s := celeris.New(celeris.Config{ }) ``` -(`celeris/config.go:121-124`, `celeris/middleware/bodylimit/doc.go`.) Enable +(`celeris/config.go` (`Config.MaxRequestBodySize`), `celeris/middleware/bodylimit/doc.go`.) Enable `ContentLengthRequired` to reject bodies that don't declare their size up-front (411 Length Required). @@ -427,10 +427,10 @@ This is the middleware that keeps you alive when the *server itself* is saturate It runs a 5-stage degradation ladder driven by three signals — **CPU utilization, in-flight request depth, and tail-latency EMA** — and sheds load according to application-defined priorities (`celeris/middleware/overload/overload.go`, -`config.go`). A background goroutine polls the signals; the hot path is a single +`celeris/middleware/overload/config.go`). A background goroutine polls the signals; the hot path is a single atomic load, so the Normal stage costs only a few nanoseconds. -The stages (`celeris/middleware/overload/config.go:17-24`): +The stages (`celeris/middleware/overload/config.go` (`Stage`)): | Stage | Action | | --- | --- | @@ -484,9 +484,9 @@ defer ctrl.Stop() // stop the poll goroutine on s How the signals compose: the poll goroutine takes the **higher** of the CPU-derived stage and the latency-derived stage, then the hot path folds in **depth**, which can only escalate *above* (never below) the polled stage -(`celeris/middleware/overload/overload.go:235-245`, `overload.go:137-155`). CPU thresholds default to +(`celeris/middleware/overload/overload.go` (`run`), `celeris/middleware/overload/overload.go` (`NewWithController`)). CPU thresholds default to Expand 0.70 / Reap 0.80 / Reorder 0.85 / Backpressure 0.90 / Reject 0.95 with 0.05 -hysteresis on downward transitions (`config.go:48-55`). Depth and latency thresholds +hysteresis on downward transitions (`celeris/middleware/overload/config.go` (`Thresholds`)). Depth and latency thresholds are **off by default** — a zero-valued field disables that signal for that stage, so you only pay for the signals you opt into. @@ -542,7 +542,7 @@ A slow-loris attacker dribbles request headers one byte at a time to pin a worke and a listener-backlog slot for the *entire* `ReadTimeout` window. `Config.ReadHeaderTimeout` caps the read of **just the request line + headers** separately from the body, killing such clients in seconds. It defaults to **10 s**; -`-1` disables it (`celeris/config.go:94-104`): +`-1` disables it (`celeris/config.go` (`Config.ReadHeaderTimeout`)): ```go s := celeris.New(celeris.Config{ @@ -611,7 +611,7 @@ fmt.Printf("requests=%d errors=%d active=%d cpu=%.2f\n", snap.RequestsTotal, snap.ErrorsTotal, snap.ActiveConns, snap.CPUUtilization) ``` -`Snapshot` fields you'll watch most (`celeris/observe/collector.go:34-51`): +`Snapshot` fields you'll watch most (`celeris/observe/collector.go` (`Snapshot`)): | Field | Meaning | | --- | --- | @@ -624,11 +624,11 @@ fmt.Printf("requests=%d errors=%d active=%d cpu=%.2f\n", | `EngineSwitches` | How many times the adaptive engine changed strategy. | The latency buckets use fixed bounds of 1 ms, 5 ms, 10 ms, 25 ms, 50 ms, 100 ms, -250 ms, 500 ms, 1 s, 5 s (`celeris/observe/collector.go:11-13`). Track the fraction +250 ms, 500 ms, 1 s, 5 s (`celeris/observe/collector.go` (`defaultBucketBounds`)). Track the fraction of requests in the high buckets to watch your tail without a full histogram backend. `snap.EngineMetrics` carries the engine-level counters that drive tuning decisions -(`celeris/observe/engine_metrics.go:22-149`): `RequestCount` (sample it twice for a rate), `ActiveConnections`, +(`celeris/observe/engine_metrics.go` (`EngineMetrics`)): `RequestCount` (sample it twice for a rate), `ActiveConnections`, `AcceptCount` / `CloseCount` (a high close-to-accept ratio means short-lived churn connections), `BytesRead` / `BytesWritten` (the bytes-per-request signal), `Workers`, and the `AsyncRoutes` / `AsyncPromotedConns` dispatch counters from earlier. @@ -652,7 +652,7 @@ If you'd rather not poll, the built-in collector stays on by default (Prometheus) and `middleware/debug` packages — see [Observability](/docs/observability) for the full metrics-export story. Disabling metrics (`DisableMetrics: true`) skips per-request recording entirely and makes -`Collector()` return `nil` (`celeris/server.go:152-154`, `celeris/server.go:863-868`) +`Collector()` return `nil` (`celeris/server.go` (`New`), `celeris/server.go` (`Server.Collector`)) — only do this if you've measured the recording cost and have an external metrics path. @@ -667,7 +667,7 @@ CPU routes `.Sync()`. **Why is my fast Redis route still blocking a worker?** Because a sub-300 µs localhost driver call is below the adaptive auto-promotion threshold. Mark the route `.UsesDriver()` (or `.Async()`) explicitly — the adaptive -net only promotes handlers slower than ~300 µs (`celeris/router.go:261-275`). +net only promotes handlers slower than ~300 µs (`celeris/router.go` (`Route.UsesDriver`)). **Does `bodylimit` protect me from a DoS?** Not on its own — it runs *after* the body is buffered. The hard ceiling is @@ -685,7 +685,7 @@ at zero (`celeris/middleware/overload/config.go`). **Do I need to enable the CPU monitor for `overload`?** No — the server wires a platform CPU monitor automatically when it starts, so `Snapshot().CPUUtilization` is populated and `overload`'s `CollectorProvider: -s.Collector` works out of the box (`celeris/server.go:947-962`, `celeris/server.go:1001-1008`). +s.Collector` works out of the box (`celeris/server.go` (`Server.doPrepare`)). ## See also