Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff6fdf00f | ||
|
|
8ac9738850 | ||
|
|
1ae5349d67 |
@@ -126,17 +126,6 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
|||||||
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
- `PIXA_CACHE_MAX_BYTES`: disk cache limit in bytes; `0` disables it;
|
||||||
default 75% of (free space + what the cache holds)
|
default 75% of (free space + what the cache holds)
|
||||||
- the rest are in the table under Configuration below
|
- the rest are in the table under Configuration below
|
||||||
- **Memory:** pixa can use up to `max_concurrent_processing` times 1.6 GiB for
|
|
||||||
the images it processes at once, plus `upstream_connections` times
|
|
||||||
`upstream_max_response_size` for the source images it has fetched that wait
|
|
||||||
for processing. 1.6 GiB is the most pixad's resident memory was measured to
|
|
||||||
grow while it processed one 8192x8192 source, the largest it accepts, into
|
|
||||||
each output format in turn, one at a time; it grew most for a 40 MB JPEG of
|
|
||||||
random pixels saved as AVIF. At the defaults that is half the CPUs pixa sees
|
|
||||||
times 1.6 GiB, plus 64 times 50 MiB: on a host where pixa sees 48 CPUs, 24
|
|
||||||
times 1.6 GiB plus 3.1 GiB, about 42 GiB. Set `PIXA_MAX_CONCURRENT_PROCESSING`
|
|
||||||
and `PIXA_UPSTREAM_CONNECTIONS` so that this fits the memory the container may
|
|
||||||
use.
|
|
||||||
- **Health check:** the image's `HEALTHCHECK` requests
|
- **Health check:** the image's `HEALTHCHECK` requests
|
||||||
`/.well-known/healthcheck.json`. upaas reads the container's health 60 seconds
|
`/.well-known/healthcheck.json`. upaas reads the container's health 60 seconds
|
||||||
after a deploy and marks the deploy failed unless it is `healthy`. The probe
|
after a deploy and marks the deploy failed unless it is `healthy`. The probe
|
||||||
@@ -526,7 +515,7 @@ tell. With no file, pixa uses the environment and the defaults.
|
|||||||
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
| `PIXA_ALLOW_HTTP` | `allow_http` | Allow plain-HTTP upstreams, for testing only; default `false` |
|
||||||
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
| `PIXA_UPSTREAM_CONNECTIONS_PER_HOST` | `upstream_connections_per_host` | Concurrent connections per upstream host; default `20` |
|
||||||
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
| `PIXA_UPSTREAM_CONNECTIONS` | `upstream_connections` | Concurrent connections to all upstream hosts together; default `64` |
|
||||||
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default half the CPUs, at least 1 |
|
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default the number of CPUs |
|
||||||
| `PIXA_IMAGE_REQUESTS_PER_MINUTE` | `image_requests_per_minute` | Image requests per client per minute; `0` turns it off; default `600` |
|
| `PIXA_IMAGE_REQUESTS_PER_MINUTE` | `image_requests_per_minute` | Image requests per client per minute; `0` turns it off; default `600` |
|
||||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||||
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||||
@@ -612,12 +601,10 @@ Key settings in more detail:
|
|||||||
does, and `downstream_timeout` has not ended first, the request is answered
|
does, and `downstream_timeout` has not ended first, the request is answered
|
||||||
503 with the error `server busy, try again later`
|
503 with the error `server busy, try again later`
|
||||||
- `max_concurrent_processing` — the most images decoded and encoded at once;
|
- `max_concurrent_processing` — the most images decoded and encoded at once;
|
||||||
default half the CPUs pixa can use (`GOMAXPROCS`, which follows a container's
|
default the number of CPUs pixa can use (`GOMAXPROCS`), which follows a
|
||||||
CPU limit), rounded down, and at least 1. A value that is not a whole number
|
container's CPU limit. A request that finds all of them in use waits up to 10
|
||||||
of at least 1 aborts startup. A request that finds all of them in use waits up
|
seconds for one to free up; if none does, and `downstream_timeout` has not
|
||||||
to 10 seconds for one to free up; if none does, and `downstream_timeout` has
|
ended first, it is answered 503 the same way
|
||||||
not ended first, it is answered 503 the same way. Each one can take a lot of
|
|
||||||
memory: see "Running under upaas"
|
|
||||||
- `image_requests_per_minute` — how many requests one client may send to the
|
- `image_requests_per_minute` — how many requests one client may send to the
|
||||||
image routes per minute (see the limit on them under Routes); default `600`.
|
image routes per minute (see the limit on them under Routes); default `600`.
|
||||||
`0` turns the limit off, and a negative or non-integer value aborts startup.
|
`0` turns the limit off, and a negative or non-integer value aborts startup.
|
||||||
@@ -644,7 +631,10 @@ See `configs/config.example.yml` for all options with defaults.
|
|||||||
- **Metrics**: Prometheus, at `/metrics`: generic HTTP request metrics
|
- **Metrics**: Prometheus, at `/metrics`: generic HTTP request metrics
|
||||||
(duration, response size, requests in flight) and the Go runtime and process
|
(duration, response size, requests in flight) and the Go runtime and process
|
||||||
metrics; requests are measured and `/metrics` is served only when
|
metrics; requests are measured and `/metrics` is served only when
|
||||||
`metrics.username` and `metrics.password` are set
|
`metrics.username` and `metrics.password` are set. A request's `handler` label
|
||||||
|
is the pattern of the route it matches, such as `/v1/image/*`, never its path.
|
||||||
|
Requests that match no route share the label `unmatched`: those the router
|
||||||
|
answers 404 or 405, and CORS preflights
|
||||||
- **Logging**: stdlib slog
|
- **Logging**: stdlib slog
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|||||||
@@ -30,16 +30,15 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-15 `max_concurrent_processing` defaults to half the CPUs pixa sees,
|
- 2026-10-15 the request metrics are labelled by route, not by path (closes
|
||||||
rounded down, and at least 1 (closes #242): it defaulted to one image per CPU,
|
#254): their `handler` label was each request's path, so every new path, a 404
|
||||||
and a container with no CPU limit sees every CPU of the host, so on a 48-CPU
|
or an encrypted URL, added series to the Prometheus registry that were never
|
||||||
host pixa processed 48 images at once, each of which can take about 1.6 GiB.
|
removed, and `/metrics` listed every encrypted URL's token. The label is now
|
||||||
`imageprocessor.DefaultMaxConcurrentProcessing` gives the default to the
|
the pattern of the chi route the request matches, such as `/v1/image/*`, or
|
||||||
config and to `imageprocessor.New`; `PIXA_MAX_CONCURRENT_PROCESSING` still
|
`unmatched` for a request that matches none: one the router answers 404 or
|
||||||
overrides it. "Running under upaas" in `README.md` gives the worst-case
|
405, or a CORS preflight. The metrics library takes the label before the
|
||||||
memory, measured, and tells the operator to set
|
request is routed, so the metrics middleware asks the router which route
|
||||||
`PIXA_MAX_CONCURRENT_PROCESSING` and `PIXA_UPSTREAM_CONNECTIONS` to fit the
|
matches.
|
||||||
container's memory.
|
|
||||||
- 2026-10-15 a `:` in the source path or query no longer moves where the path
|
- 2026-10-15 a `:` in the source path or query no longer moves where the path
|
||||||
ends and the query begins (closes #247): the signed data and the cache key
|
ends and the query begins (closes #247): the signed data and the cache key
|
||||||
joined host, path and query with `:` and no escaping, so a URL signed for
|
joined host, path and query with `:` and no escaping, so a URL signed for
|
||||||
|
|||||||
@@ -108,11 +108,10 @@ upstream_connections_per_host: 20
|
|||||||
# downstream_timeout has ended first.
|
# downstream_timeout has ended first.
|
||||||
upstream_connections: 64
|
upstream_connections: 64
|
||||||
|
|
||||||
# Maximum number of images decoded and encoded at once (default: half the
|
# Maximum number of images decoded and encoded at once (default: the
|
||||||
# number of CPUs pixa can use, at least 1; the number of CPUs follows a
|
# number of CPUs pixa can use, which follows a container's CPU limit). A
|
||||||
# container's CPU limit). A request that finds none free waits up to 10
|
# request that finds none free waits up to 10 seconds for one, and if none
|
||||||
# seconds for one, and if none frees up it is answered 503, unless
|
# frees up it is answered 503, unless downstream_timeout has ended first.
|
||||||
# downstream_timeout has ended first.
|
|
||||||
# max_concurrent_processing: 4
|
# max_concurrent_processing: 4
|
||||||
|
|
||||||
# How many requests to the image routes (/v1/image/ and /v1/e/) one client
|
# How many requests to the image routes (/v1/image/ and /v1/e/) one client
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -13,8 +12,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
||||||
// max_concurrent_processing is half the CPUs Go uses, and at least 1, and
|
// max_concurrent_processing is the number of CPUs Go uses and an omitted
|
||||||
// an omitted upstream_connections is 64.
|
// upstream_connections is 64.
|
||||||
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -23,9 +22,9 @@ func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
|||||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
t.Fatalf("minimal config should be valid, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.MaxConcurrentProcessing != max(runtime.GOMAXPROCS(0)/2, 1) {
|
if c.MaxConcurrentProcessing != runtime.GOMAXPROCS(0) {
|
||||||
t.Errorf("MaxConcurrentProcessing = %d, want %d, half the CPUs",
|
t.Errorf("MaxConcurrentProcessing = %d, want %d, one per CPU",
|
||||||
c.MaxConcurrentProcessing, max(runtime.GOMAXPROCS(0)/2, 1))
|
c.MaxConcurrentProcessing, runtime.GOMAXPROCS(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.UpstreamConnections != 64 {
|
if c.UpstreamConnections != 64 {
|
||||||
@@ -33,36 +32,6 @@ func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOmittedMaxConcurrentProcessingIsHalfTheCPUsAndAtLeastOne checks
|
|
||||||
// that an omitted max_concurrent_processing is half the CPUs Go uses,
|
|
||||||
// rounded down, and 1 when Go uses one CPU.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
|
|
||||||
func TestOmittedMaxConcurrentProcessingIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
|
|
||||||
cpusBefore := runtime.GOMAXPROCS(0)
|
|
||||||
|
|
||||||
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
|
|
||||||
|
|
||||||
for _, tc := range []struct{ cpus, want int }{
|
|
||||||
{1, 1}, {2, 1}, {3, 1}, {8, 4},
|
|
||||||
} {
|
|
||||||
runtime.GOMAXPROCS(tc.cpus)
|
|
||||||
|
|
||||||
c, err := configFromYAML(t, signingKeyLine)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("minimal config should be valid, got error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("GOMAXPROCS %d: MaxConcurrentProcessing %d",
|
|
||||||
tc.cpus, c.MaxConcurrentProcessing)
|
|
||||||
|
|
||||||
if c.MaxConcurrentProcessing != tc.want {
|
|
||||||
t.Errorf("GOMAXPROCS %d: MaxConcurrentProcessing = %d, want %d",
|
|
||||||
tc.cpus, c.MaxConcurrentProcessing, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
|
// TestExplicitConcurrencyLimitsAreUsed checks that valid values for the
|
||||||
// two limits are used as given.
|
// two limits are used as given.
|
||||||
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
|
func TestExplicitConcurrencyLimitsAreUsed(t *testing.T) {
|
||||||
@@ -191,19 +160,3 @@ func TestConcurrencyLimitsFromEnvironment(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInvalidMaxConcurrentProcessingFromEnvironmentAbortsStartup checks
|
|
||||||
// that a PIXA_MAX_CONCURRENT_PROCESSING that is not a whole number of at
|
|
||||||
// least 1, an empty one included, aborts startup naming the variable and
|
|
||||||
// the key instead of taking the default.
|
|
||||||
func TestInvalidMaxConcurrentProcessingFromEnvironmentAbortsStartup(t *testing.T) {
|
|
||||||
for _, value := range []string{"", " ", "0.5", "half", "-1", "4 images"} {
|
|
||||||
t.Run(fmt.Sprintf("value %q", value), func(t *testing.T) {
|
|
||||||
t.Setenv(testMaxConcurrentProcessingVar, value)
|
|
||||||
|
|
||||||
_, err := configFromYAML(t, signingKeyLine)
|
|
||||||
wantStartupError(t, err,
|
|
||||||
testMaxConcurrentProcessingVar, keyMaxConcurrentProcessing)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,7 +21,6 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -297,8 +297,10 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||||
UpstreamConnections: loader.intVal(
|
UpstreamConnections: loader.intVal(
|
||||||
keyUpstreamConnections, DefaultUpstreamConnections),
|
keyUpstreamConnections, DefaultUpstreamConnections),
|
||||||
MaxConcurrentProcessing: loader.intVal(keyMaxConcurrentProcessing,
|
// Decoding and encoding are CPU-bound, so the default is one image
|
||||||
imageprocessor.DefaultMaxConcurrentProcessing()),
|
// per CPU Go uses, which follows a container's CPU limit.
|
||||||
|
MaxConcurrentProcessing: loader.intVal(
|
||||||
|
keyMaxConcurrentProcessing, runtime.GOMAXPROCS(0)),
|
||||||
UpstreamFetchTimeout: loader.durationVal(
|
UpstreamFetchTimeout: loader.durationVal(
|
||||||
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
||||||
UpstreamMaxResponseSize: loader.int64Val(
|
UpstreamMaxResponseSize: loader.int64Val(
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import (
|
|||||||
var vipsOnce sync.Once
|
var vipsOnce sync.Once
|
||||||
|
|
||||||
// initVips initializes libvips with quiet logging, one worker thread per
|
// initVips initializes libvips with quiet logging, one worker thread per
|
||||||
// image and no operation cache. With one thread each, the images processed
|
// image and no operation cache. Process already works on one image per CPU
|
||||||
// at once use no more CPUs than MaxConcurrentProcessing. Each request
|
// by default, so more threads per image would only compete for the CPUs.
|
||||||
// decodes different source bytes, so the operation cache would rarely be
|
// Each request decodes different source bytes, so the operation cache
|
||||||
// hit and would hold memory outside MaxConcurrentProcessing; repeated
|
// would rarely be hit and would hold memory outside MaxConcurrentProcessing;
|
||||||
// requests are served from pixa's disk cache instead.
|
// repeated requests are served from pixa's disk cache instead.
|
||||||
func initVips() {
|
func initVips() {
|
||||||
vipsOnce.Do(func() {
|
vipsOnce.Do(func() {
|
||||||
vips.LoggingSettings(nil, vips.LogLevelError)
|
vips.LoggingSettings(nil, vips.LogLevelError)
|
||||||
@@ -179,19 +179,10 @@ type Params struct {
|
|||||||
// If <= 0, DefaultMaxInputBytes is used.
|
// If <= 0, DefaultMaxInputBytes is used.
|
||||||
MaxInputBytes int64
|
MaxInputBytes int64
|
||||||
// MaxConcurrentProcessing is the most images processed at once.
|
// MaxConcurrentProcessing is the most images processed at once.
|
||||||
// If <= 0, DefaultMaxConcurrentProcessing() is used.
|
// If <= 0, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultMaxConcurrentProcessing returns the most images processed at once
|
|
||||||
// when none is set: half the CPUs Go uses (runtime.GOMAXPROCS(0), which
|
|
||||||
// follows a container's CPU limit), rounded down, and at least 1.
|
|
||||||
func DefaultMaxConcurrentProcessing() int {
|
|
||||||
const cpusPerImage = 2
|
|
||||||
|
|
||||||
return max(runtime.GOMAXPROCS(0)/cpusPerImage, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates a new image processor with the given parameters.
|
// New creates a new image processor with the given parameters.
|
||||||
// A zero-value Params{} uses sensible defaults.
|
// A zero-value Params{} uses sensible defaults.
|
||||||
func New(params Params) *ImageProcessor {
|
func New(params Params) *ImageProcessor {
|
||||||
@@ -204,7 +195,7 @@ func New(params Params) *ImageProcessor {
|
|||||||
|
|
||||||
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
||||||
if maxConcurrentProcessing <= 0 {
|
if maxConcurrentProcessing <= 0 {
|
||||||
maxConcurrentProcessing = DefaultMaxConcurrentProcessing()
|
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ImageProcessor{
|
return &ImageProcessor{
|
||||||
|
|||||||
@@ -115,14 +115,14 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
|
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, limit := range []int{0, -1} {
|
for _, limit := range []int{0, -1} {
|
||||||
proc := New(Params{MaxConcurrentProcessing: limit})
|
proc := New(Params{MaxConcurrentProcessing: limit})
|
||||||
if got := cap(proc.processingSemaphore); got != max(runtime.GOMAXPROCS(0)/2, 1) {
|
if got := cap(proc.processingSemaphore); got != runtime.GOMAXPROCS(0) {
|
||||||
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, half the CPUs",
|
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
||||||
limit, got, max(runtime.GOMAXPROCS(0)/2, 1))
|
limit, got, runtime.GOMAXPROCS(0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,30 +132,6 @@ func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewDefaultIsHalfTheCPUsAndAtLeastOne checks that New, given no
|
|
||||||
// MaxConcurrentProcessing, makes one slot for every two CPUs Go uses,
|
|
||||||
// rounded down, and one slot when Go uses one CPU.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // it sets GOMAXPROCS, which other tests read
|
|
||||||
func TestNewDefaultIsHalfTheCPUsAndAtLeastOne(t *testing.T) {
|
|
||||||
cpusBefore := runtime.GOMAXPROCS(0)
|
|
||||||
|
|
||||||
t.Cleanup(func() { runtime.GOMAXPROCS(cpusBefore) })
|
|
||||||
|
|
||||||
for _, tc := range []struct{ cpus, want int }{
|
|
||||||
{1, 1}, {2, 1}, {3, 1}, {8, 4},
|
|
||||||
} {
|
|
||||||
runtime.GOMAXPROCS(tc.cpus)
|
|
||||||
|
|
||||||
got := cap(New(Params{}).processingSemaphore)
|
|
||||||
t.Logf("GOMAXPROCS %d: %d slots", tc.cpus, got)
|
|
||||||
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("GOMAXPROCS %d: %d slots, want %d", tc.cpus, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
// TestProcessNeverExceedsMaxConcurrentProcessing starts more Process calls
|
||||||
// than MaxConcurrentProcessing allows and holds the first ones inside
|
// than MaxConcurrentProcessing allows and holds the first ones inside
|
||||||
// Process until the test lets them go. No more than the limit may be
|
// Process until the test lets them go. No more than the limit may be
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type ServiceConfig struct {
|
|||||||
// Allowlist is the list of hosts that don't require signatures
|
// Allowlist is the list of hosts that don't require signatures
|
||||||
Allowlist []string
|
Allowlist []string
|
||||||
// MaxConcurrentProcessing is the most images processed at once; zero
|
// MaxConcurrentProcessing is the most images processed at once; zero
|
||||||
// uses the image processor's default, half the CPUs
|
// uses the image processor's default, one per CPU
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
// Logger for logging
|
// Logger for logging
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
basicauth "github.com/99designs/basicauth-go"
|
basicauth "github.com/99designs/basicauth-go"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/go-chi/httprate"
|
"github.com/go-chi/httprate"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
metrics "github.com/slok/go-http-metrics/metrics/prometheus"
|
||||||
ghmm "github.com/slok/go-http-metrics/middleware"
|
ghmm "github.com/slok/go-http-metrics/middleware"
|
||||||
"github.com/slok/go-http-metrics/middleware/std"
|
"github.com/slok/go-http-metrics/middleware/std"
|
||||||
@@ -63,6 +65,10 @@ type Middleware struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
config *config.Config
|
config *config.Config
|
||||||
clientIP *clientip.Resolver
|
clientIP *clientip.Resolver
|
||||||
|
|
||||||
|
// metricsRegistry is the registry Metrics registers its metrics with.
|
||||||
|
// nil is Prometheus' default registry, the one /metrics serves.
|
||||||
|
metricsRegistry prometheus.Registerer
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Middleware instance.
|
// New creates a new Middleware instance.
|
||||||
@@ -209,17 +215,50 @@ func (s *Middleware) CORS() func(http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics returns a Prometheus metrics middleware.
|
// Metrics returns a Prometheus metrics middleware. Its handler label is the
|
||||||
|
// pattern of the chi route a request matches, such as /v1/image/*, or
|
||||||
|
// "unmatched" for a request that matches none, so the routes fix how many
|
||||||
|
// values the label has. It is never the path, with which any client could
|
||||||
|
// add series to the registry without limit.
|
||||||
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
func (s *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
mdlw := ghmm.New(ghmm.Config{
|
mdlw := ghmm.New(ghmm.Config{
|
||||||
Recorder: metrics.NewRecorder(metrics.Config{}),
|
Recorder: metrics.NewRecorder(metrics.Config{
|
||||||
|
Registry: s.metricsRegistry,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return std.Handler("", mdlw, next)
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
std.Handler(routePattern(r), mdlw, next).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routePattern returns the pattern of the chi route r matches, or
|
||||||
|
// "unmatched" when it matches none or is not served by a chi router. The
|
||||||
|
// metrics library takes the label before the request is routed, so the
|
||||||
|
// router is asked which route matches, with the path it routes on:
|
||||||
|
// r.URL.RawPath when set, else r.URL.Path.
|
||||||
|
func routePattern(r *http.Request) string {
|
||||||
|
pattern := ""
|
||||||
|
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
if rctx != nil {
|
||||||
|
path := r.URL.RawPath
|
||||||
|
if path == "" {
|
||||||
|
path = r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = rctx.Routes.Find(chi.NewRouteContext(), r.Method, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pattern == "" {
|
||||||
|
return "unmatched"
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
|
|
||||||
// MetricsAuth returns a basic auth middleware for the metrics endpoint.
|
// MetricsAuth returns a basic auth middleware for the metrics endpoint.
|
||||||
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||||
return basicauth.New(
|
return basicauth.New(
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/config"
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
@@ -198,7 +201,7 @@ func TestMetricsRecordsServedRequest(t *testing.T) {
|
|||||||
|
|
||||||
// The line /metrics shows once one GET /test has been served.
|
// The line /metrics shows once one GET /test has been served.
|
||||||
const want = `http_request_duration_seconds_count{` +
|
const want = `http_request_duration_seconds_count{` +
|
||||||
`code="200",handler="/test",method="GET",service=""} 1`
|
`code="200",handler="unmatched",method="GET",service=""} 1`
|
||||||
|
|
||||||
mw := &Middleware{log: slog.Default(), config: &config.Config{}}
|
mw := &Middleware{log: slog.Default(), config: &config.Config{}}
|
||||||
|
|
||||||
@@ -220,6 +223,86 @@ func TestMetricsRecordsServedRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMetricsLabelsRequestsByRoute checks that the metrics middleware labels
|
||||||
|
// each request with the pattern of the route that answers it, never with its
|
||||||
|
// path: two paths one route answers share that route's pattern, and requests
|
||||||
|
// no route answers, a 404 or a 405, share "unmatched", among them requests for
|
||||||
|
// the path the subrouter is mounted at, /v1 and /v1/. With a label per path,
|
||||||
|
// any client could add series to the registry without limit. The middleware
|
||||||
|
// registers with a registry of its own here, as another test in this package
|
||||||
|
// registers with Prometheus' default registry, which can be done only once.
|
||||||
|
func TestMetricsLabelsRequestsByRoute(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
mw := &Middleware{
|
||||||
|
log: slog.Default(),
|
||||||
|
config: &config.Config{},
|
||||||
|
metricsRegistry: registry,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image route is in a subrouter, as in pixa's router.
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(mw.Metrics())
|
||||||
|
router.Route("/v1", func(r chi.Router) {
|
||||||
|
r.Get("/image/*", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, req := range []struct{ method, path string }{
|
||||||
|
{http.MethodGet, "/v1/image/a.example.com/one.jpg/1x1.png"},
|
||||||
|
{http.MethodGet, "/v1/image/b.example.com/two.jpg/1x1.png"},
|
||||||
|
{http.MethodGet, "/no-such-route"},
|
||||||
|
{http.MethodGet, "/v1/no-such-route"},
|
||||||
|
{http.MethodGet, "/v1"},
|
||||||
|
{http.MethodGet, "/v1/"},
|
||||||
|
{http.MethodPost, "/v1/image/a.example.com/one.jpg/1x1.png"},
|
||||||
|
} {
|
||||||
|
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(
|
||||||
|
t.Context(), req.method, req.path, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := requestCountsByHandler(t, registry)
|
||||||
|
t.Logf("requests counted by handler label: %v", got)
|
||||||
|
|
||||||
|
want := map[string]uint64{"/v1/image/*": 2, "unmatched": 5}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("requests counted by handler label = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestCountsByHandler returns how many requests the request duration
|
||||||
|
// histogram in registry counted under each value of its handler label.
|
||||||
|
func requestCountsByHandler(
|
||||||
|
t *testing.T, registry *prometheus.Registry,
|
||||||
|
) map[string]uint64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
families, err := registry.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry.Gather() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := map[string]uint64{}
|
||||||
|
|
||||||
|
for _, family := range families {
|
||||||
|
if family.GetName() != "http_request_duration_seconds" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, metric := range family.GetMetric() {
|
||||||
|
for _, label := range metric.GetLabel() {
|
||||||
|
if label.GetName() == "handler" {
|
||||||
|
counts[label.GetValue()] += metric.GetHistogram().GetSampleCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoggingLeavesOutSubmittedSigningKey checks that a login, a POST /
|
// TestLoggingLeavesOutSubmittedSigningKey checks that a login, a POST /
|
||||||
// whose form carries the signing key, leaves no trace of the key in the
|
// whose form carries the signing key, leaves no trace of the key in the
|
||||||
// request's log line.
|
// request's log line.
|
||||||
|
|||||||
Reference in New Issue
Block a user