mirror of
https://github.com/soypat/lneto.git
synced 2026-08-20 06:29:03 +00:00
httphi router refactor (#176)
* httphi: RouterConfig refactor to enable DefaultRouterConfig * RequestHeader not case sensitive anymore * httphi: use DefaultRouterConfig in examples * httphi: remove gated stage complexity Misusing Stage methods by calling them once header has been written is totally harmless as far as I can tell. We simplify the codebase on this occasion by removing the headerWritten check for all stage methods * httphi: improve APIs * httphi: remove status
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package httphi
|
||||
|
||||
import (
|
||||
"math"
|
||||
"unsafe"
|
||||
|
||||
"github.com/soypat/lneto/http/httpraw"
|
||||
)
|
||||
|
||||
const (
|
||||
// minRequestHeaderBuffer is the smallest request buffer [httpraw.Header]
|
||||
// accepts with buffer growth disabled, which is how exchanges are configured.
|
||||
minRequestHeaderBuffer = 32
|
||||
// minResponseHeaderBuffer is the room [Exchange.FlushHeader] needs for the
|
||||
// CRLF closing the header block, written even when no field was staged.
|
||||
minResponseHeaderBuffer = len("\r\n")
|
||||
// maxExchangeBuffer bounds an exchange's whole buffer: [Exchange] indexes it
|
||||
// with uint16 offsets, so a larger one would be addressed truncated.
|
||||
maxExchangeBuffer = math.MaxUint16
|
||||
|
||||
// sizeofExchange is the fixed cost of an exchange, which a Router pays per
|
||||
// connection it can serve concurrently on top of the buffers it hands it.
|
||||
// It dwarfs a small request buffer, so budgets must account for it.
|
||||
sizeofExchange = int(unsafe.Sizeof(Exchange{}))
|
||||
// sizeofPathValue is the per-wildcard cost of the path value table.
|
||||
sizeofPathValue = int(unsafe.Sizeof(PathValue{}))
|
||||
// sizeofJob is an exchange's slot in the queue connections wait on for a
|
||||
// worker goroutine, sized to the goroutine count in worker mode.
|
||||
sizeofJob = int(unsafe.Sizeof(job{}))
|
||||
|
||||
// bytesPerHeaderField is the request buffer [DefaultRouterConfig] budgets per
|
||||
// parseable header field. Real fields run a little longer than this
|
||||
// ("Accept-Encoding: gzip, deflate, br\r\n" is 35 bytes), so a request fills
|
||||
// the buffer before it exhausts the field table, which is the cheaper of the
|
||||
// two limits to hit: growing the table costs [httpraw.SizeKV] per field on top
|
||||
// of the bytes the field already occupies.
|
||||
bytesPerHeaderField = 32
|
||||
// defaultResponseHeaderBuffer is the response header room
|
||||
// [DefaultRouterConfig] reserves when the budget can afford it: enough for a
|
||||
// Content-Type, a Content-Length and a Connection field with room to spare.
|
||||
// It does not scale with the request buffer because what a response header
|
||||
// costs depends on the fields a handler stages, not on the request's size.
|
||||
defaultResponseHeaderBuffer = 128
|
||||
)
|
||||
|
||||
// MemoryUsagePerConnection returns the heap bytes a [Router] configured with cfg
|
||||
// reserves for each connection it can serve concurrently, maxPathValues being
|
||||
// the [Mux.MaxPathValues] of the mux it is configured with. Goroutine stacks are
|
||||
// not counted: those are the runtime's to size, not the router's.
|
||||
//
|
||||
// In worker mode this is exact and fixed, so a router's whole heap footprint is
|
||||
// this times FixedNumGoroutines, plus the runtime's own header for the job
|
||||
// queue. With FixedNumGoroutines -1 the router allocates one of these per
|
||||
// connection in flight instead, so the total grows with peak concurrency.
|
||||
//
|
||||
// It is the inverse of [DefaultRouterConfig] and useful to check a hand written
|
||||
// configuration against a memory budget.
|
||||
func (cfg RouterConfig) MemoryUsagePerConnection(maxPathValues int) int {
|
||||
if maxPathValues < 0 {
|
||||
maxPathValues = 0 // Mux with no routes registered yet, see [Mux.MaxPathValues].
|
||||
}
|
||||
n := sizeofExchange + // Exchange itself, an element of the router's exchange store.
|
||||
cfg.RequestHeaderBufferSize + cfg.ResponseHeaderMinBufferSize + // Its window into the raw buffer.
|
||||
cfg.RequestNumHeaderKVCap*httpraw.SizeKV + // The request header's field table.
|
||||
maxPathValues*sizeofPathValue // Its window into the path value store.
|
||||
if cfg.workerMode() {
|
||||
n += sizeofJob // Slot in the queue connections wait on for a worker.
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// DefaultRouterConfig is a general purpose configuration creator
|
||||
// for small, medium, large, performant or embedded projects.
|
||||
//
|
||||
// Generalness is achieved with parameters that let the Configuration
|
||||
// determine allocation buffer sizes based on typical usage for that
|
||||
// number of goroutines and heap allocation on a per-connection basis.
|
||||
func DefaultRouterConfig(numGoroutines, memoryPerConnectionBytes, maxPathValues int) RouterConfig {
|
||||
// The budget is spent on the request header buffer first, since that is what
|
||||
// decides which requests are answered at all, then on a field table sized to
|
||||
// match it and a small response header reserve. A budget too small to fund the
|
||||
// minimum viable exchange yields the minimum instead, so the returned config is
|
||||
// always one [Router.Configure] accepts but may exceed a budget under roughly
|
||||
// sizeofExchange + 200 bytes. Check it with MemoryUsagePerConnection when the
|
||||
// bound has to hold.
|
||||
if numGoroutines <= 0 {
|
||||
numGoroutines = -1 // Unbounded mode, the only non-positive value Validate accepts.
|
||||
}
|
||||
// Everything the exchange costs before any buffer is sized: subtract it first
|
||||
// so the buffers below divide up what is actually left to spend.
|
||||
fixed := sizeofExchange + maxPathValues*sizeofPathValue
|
||||
if numGoroutines > 0 {
|
||||
fixed += sizeofJob
|
||||
}
|
||||
// A budget past what the buffers may grow to is only spendable up to the cap
|
||||
// below, so clamp before the products: on a 32 bit target an unclamped
|
||||
// multiply would overflow and wrap a generous budget into a tiny buffer.
|
||||
const maxSpendable = (maxExchangeBuffer + defaultResponseHeaderBuffer) *
|
||||
(bytesPerHeaderField + httpraw.SizeKV) / bytesPerHeaderField
|
||||
avail := min(memoryPerConnectionBytes-fixed, maxSpendable)
|
||||
|
||||
// The response reserve is a floor rather than a share of the budget, but a
|
||||
// budget this small cannot afford the full one without starving the request.
|
||||
respBuf := min(defaultResponseHeaderBuffer, avail/4)
|
||||
|
||||
// Solve avail-respBuf = reqBuf + reqBuf/bytesPerHeaderField*httpraw.SizeKV for
|
||||
// reqBuf, the field table growing with the buffer it parses.
|
||||
reqBuf := (avail - respBuf) * bytesPerHeaderField / (bytesPerHeaderField + httpraw.SizeKV)
|
||||
|
||||
// Clamp to what [RouterConfig.Validate] accepts. Truncating division above
|
||||
// keeps the result under budget; these floors are what can push it over.
|
||||
respBuf = max(respBuf, minResponseHeaderBuffer)
|
||||
reqBuf = max(reqBuf, minRequestHeaderBuffer)
|
||||
reqBuf = min(reqBuf, maxExchangeBuffer-respBuf)
|
||||
return RouterConfig{
|
||||
FixedNumGoroutines: numGoroutines,
|
||||
RequestHeaderBufferSize: reqBuf,
|
||||
ResponseHeaderMinBufferSize: respBuf,
|
||||
RequestNumHeaderKVCap: max(reqBuf/bytesPerHeaderField, 1),
|
||||
NormalizeOutgoingKeys: false,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user