Compare commits
9
Commits
296a26097d
...
d9febdd87c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9febdd87c | ||
|
|
0abbeaf215 | ||
|
|
a48791feb8 | ||
|
|
2322b4dba9 | ||
|
|
e17536a835 | ||
|
|
6f546aa4c6 | ||
|
|
f9a4c5766c | ||
|
|
efc908b9ba | ||
|
|
2ab96155ba |
@@ -130,6 +130,32 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
|||||||
the cache can grow to three quarters or more of the space they all have
|
the cache can grow to three quarters or more of the space they all have
|
||||||
left, and to all of it when that is 500 MiB or less.
|
left, and to all of it when that is 500 MiB or less.
|
||||||
- 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 4.2 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. 4.2 GiB is the most resident memory was measured to grow while
|
||||||
|
pixa, with pixad's libvips settings, processed one 8192x8192 source, the
|
||||||
|
largest it accepts, saving it at full size to each output format in turn, one
|
||||||
|
at a time. The sources were a JPEG, a GIF, a WebP, an 8-bit and a 16-bit PNG,
|
||||||
|
an 8-bit and a 12-bit AVIF, and an 8-bit, a 16-bit and a 32-bit floating-point
|
||||||
|
JPEG XL; memory grew most for the 16-bit JPEG XL, of random pixels, saved as
|
||||||
|
WebP. At the defaults that is half the CPUs pixa sees times 4.2 GiB, plus 64
|
||||||
|
times 50 MiB: on a host where pixa sees 48 CPUs, 24 times 4.2 GiB plus 3.1
|
||||||
|
GiB, about 104 GiB. Set `PIXA_MAX_CONCURRENT_PROCESSING` and
|
||||||
|
`PIXA_UPSTREAM_CONNECTIONS` so that this fits the memory the container may
|
||||||
|
use.
|
||||||
|
- **Temporary files:** while pixa processes a large source, libvips writes its
|
||||||
|
decoded pixels to a file in `/tmp` and deletes the file when the image is
|
||||||
|
done. For a GIF, WebP, AVIF or JPEG XL source the decoded pixels are held in
|
||||||
|
memory as well as written to that file, and the 4.2 GiB above includes that
|
||||||
|
memory. For an 8192x8192 source the file was measured on pixad at 192 MiB for
|
||||||
|
an 8-bit RGB JPEG, 512 MiB for a 16-bit RGBA PNG or JPEG XL, and 1 GiB for a
|
||||||
|
JPEG XL of 32-bit floating-point RGBA, the largest measured: up to 1 GiB per
|
||||||
|
image processed at once, 24 GiB at the defaults on a host where pixa sees 48
|
||||||
|
CPUs. This `/tmp` is in the container's own filesystem, not on the volume at
|
||||||
|
`/var/lib/pixa`, and `cache_max_bytes` does not count it. The 4.2 GiB above
|
||||||
|
does not include these files; where `/tmp` is a tmpfs, they are memory on top
|
||||||
|
of it.
|
||||||
- **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
|
||||||
@@ -164,6 +190,14 @@ name.
|
|||||||
Multiple source paths may reference the same content blob; the database tracks
|
Multiple source paths may reference the same content blob; the database tracks
|
||||||
references rather than using filesystem refcounting.
|
references rather than using filesystem refcounting.
|
||||||
|
|
||||||
|
Source and transformed image files, and the `.meta` file beside each transformed
|
||||||
|
image, are written under a temporary name, synced to disk, and only then renamed
|
||||||
|
into place, so a crash leaves no file under its own name with part of its data.
|
||||||
|
When pixa starts, and every five minutes after, it removes each cached source or
|
||||||
|
transformed image whose file is empty, or not the size the database records for
|
||||||
|
it, with a warning naming the file; the next request for it fetches the source
|
||||||
|
or converts the image again.
|
||||||
|
|
||||||
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
pixa's target is 1-5k r/s, which has not been measured at that rate (see Load
|
||||||
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
Test). Toward it, pixa keeps in memory the content types of the 10,000
|
||||||
transformed images most recently cached or served, so a cache hit on one of them
|
transformed images most recently cached or served, so a cache hit on one of them
|
||||||
@@ -545,7 +579,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 the number of CPUs |
|
| `PIXA_MAX_CONCURRENT_PROCESSING` | `max_concurrent_processing` | Images processed at once; default half the CPUs, at least 1 |
|
||||||
| `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 |
|
||||||
@@ -631,10 +665,12 @@ 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 the number of CPUs pixa can use (`GOMAXPROCS`), which follows a
|
default half the CPUs pixa can use (`GOMAXPROCS`, which follows a container's
|
||||||
container's CPU limit. A request that finds all of them in use waits up to 10
|
CPU limit), rounded down, and at least 1. A value that is not a whole number
|
||||||
seconds for one to free up; if none does, and `downstream_timeout` has not
|
of at least 1 aborts startup. A request that finds all of them in use waits up
|
||||||
ended first, it is answered 503 the same way
|
to 10 seconds for one to free up; if none does, and `downstream_timeout` has
|
||||||
|
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.
|
||||||
|
|||||||
@@ -30,6 +30,30 @@ P2: security: per-origin rate limiting
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-15 `max_concurrent_processing` defaults to half the CPUs pixa sees,
|
||||||
|
rounded down, and at least 1 (closes #242): it defaulted to one image per CPU,
|
||||||
|
and a container with no CPU limit sees every CPU of the host, so on a 48-CPU
|
||||||
|
host pixa processed 48 images at once, each of which can take about 4.2 GiB.
|
||||||
|
`config.DefaultMaxConcurrentProcessing` gives the default to the config and to
|
||||||
|
`imageprocessor.New`; `PIXA_MAX_CONCURRENT_PROCESSING` still overrides it.
|
||||||
|
"Running under upaas" in `README.md` gives the worst-case memory, measured,
|
||||||
|
and tells the operator to set `PIXA_MAX_CONCURRENT_PROCESSING` and
|
||||||
|
`PIXA_UPSTREAM_CONNECTIONS` to fit the container's memory. It also gives the
|
||||||
|
file libvips writes to `/tmp` for each large source, up to 1 GiB, measured,
|
||||||
|
which is outside `/var/lib/pixa` and `cache_max_bytes` and is memory where
|
||||||
|
`/tmp` is a tmpfs.
|
||||||
|
- 2026-10-15 a cached file left empty or partial is no longer served, or fetched
|
||||||
|
again on every request (closes #252). Source and transformed image files, and
|
||||||
|
each transformed image's `.meta` file, are synced to disk before they are
|
||||||
|
renamed into place, and their directory after; the `.meta` file is written
|
||||||
|
before the image, and a failed directory sync removes the renamed file. The
|
||||||
|
reconciliation pass, at startup and every five minutes, removes a cached
|
||||||
|
source or transformed image whose file is empty, or not the size the database
|
||||||
|
records for it, with its rows and a warning naming the file, so the next
|
||||||
|
request fetches the source or converts the image again; requests compare no
|
||||||
|
sizes. Storing a source over a file of another size replaces that file. A
|
||||||
|
source that fails to decode is not removed for that alone: a damaged upstream
|
||||||
|
image would then be fetched on every request.
|
||||||
- 2026-10-15 a transcode stops once its request's `downstream_timeout` has
|
- 2026-10-15 a transcode stops once its request's `downstream_timeout` has
|
||||||
ended, unless it has begun encoding (closes #243): the image processor looked
|
ended, unless it has begun encoding (closes #243): the image processor looked
|
||||||
at its context only while it waited for a processing slot, so an image was
|
at its context only while it waited for a processing slot, so an image was
|
||||||
|
|||||||
@@ -108,10 +108,11 @@ 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: the
|
# Maximum number of images decoded and encoded at once (default: half the
|
||||||
# number of CPUs pixa can use, which follows a container's CPU limit). A
|
# number of CPUs pixa can use, at least 1; the number of CPUs follows a
|
||||||
# request that finds none free waits up to 10 seconds for one, and if none
|
# container's CPU limit). A request that finds none free waits up to 10
|
||||||
# frees up it is answered 503, unless downstream_timeout has ended first.
|
# seconds for one, and if none frees up it is answered 503, unless
|
||||||
|
# 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,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -12,8 +13,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
// TestOmittedConcurrencyLimitsUseDefaults checks that an omitted
|
||||||
// max_concurrent_processing is the number of CPUs Go uses and an omitted
|
// max_concurrent_processing is half the CPUs Go uses, and at least 1, and
|
||||||
// upstream_connections is 64.
|
// an omitted upstream_connections is 64.
|
||||||
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
func TestOmittedConcurrencyLimitsUseDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -22,9 +23,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 != runtime.GOMAXPROCS(0) {
|
if c.MaxConcurrentProcessing != max(runtime.GOMAXPROCS(0)/2, 1) {
|
||||||
t.Errorf("MaxConcurrentProcessing = %d, want %d, one per CPU",
|
t.Errorf("MaxConcurrentProcessing = %d, want %d, half the CPUs",
|
||||||
c.MaxConcurrentProcessing, runtime.GOMAXPROCS(0))
|
c.MaxConcurrentProcessing, max(runtime.GOMAXPROCS(0)/2, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.UpstreamConnections != 64 {
|
if c.UpstreamConnections != 64 {
|
||||||
@@ -32,6 +33,36 @@ 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) {
|
||||||
@@ -160,3 +191,19 @@ 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ const (
|
|||||||
DefaultImageRequestsPerMinute = 600
|
DefaultImageRequestsPerMinute = 600
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultMaxConcurrentProcessing returns the most images processed at once
|
||||||
|
// when max_concurrent_processing is omitted: half the CPUs Go uses
|
||||||
|
// (runtime.GOMAXPROCS(0), which follows a container's CPU limit), rounded
|
||||||
|
// down, and at least 1.
|
||||||
|
func DefaultMaxConcurrentProcessing() int {
|
||||||
|
return max(runtime.GOMAXPROCS(0)/2, 1) //nolint:mnd // half the CPUs
|
||||||
|
}
|
||||||
|
|
||||||
// Configuration key names.
|
// Configuration key names.
|
||||||
const (
|
const (
|
||||||
keyDebug = "debug"
|
keyDebug = "debug"
|
||||||
@@ -297,10 +305,8 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
|
|||||||
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
keyUpstreamConnectionsPerHost, DefaultUpstreamConnectionsPerHost),
|
||||||
UpstreamConnections: loader.intVal(
|
UpstreamConnections: loader.intVal(
|
||||||
keyUpstreamConnections, DefaultUpstreamConnections),
|
keyUpstreamConnections, DefaultUpstreamConnections),
|
||||||
// Decoding and encoding are CPU-bound, so the default is one image
|
|
||||||
// per CPU Go uses, which follows a container's CPU limit.
|
|
||||||
MaxConcurrentProcessing: loader.intVal(
|
MaxConcurrentProcessing: loader.intVal(
|
||||||
keyMaxConcurrentProcessing, runtime.GOMAXPROCS(0)),
|
keyMaxConcurrentProcessing, DefaultMaxConcurrentProcessing()),
|
||||||
UpstreamFetchTimeout: loader.durationVal(
|
UpstreamFetchTimeout: loader.durationVal(
|
||||||
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
keyUpstreamFetchTimeout, DefaultUpstreamFetchTimeout),
|
||||||
UpstreamMaxResponseSize: loader.int64Val(
|
UpstreamMaxResponseSize: loader.int64Val(
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"runtime"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/davidbyttow/govips/v2/vips"
|
"github.com/davidbyttow/govips/v2/vips"
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vipsOnce ensures vips is initialized exactly once.
|
// vipsOnce ensures vips is initialized exactly once.
|
||||||
@@ -20,11 +20,10 @@ 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. Process already works on one image per CPU
|
// image and no operation cache. Each request decodes different source
|
||||||
// by default, so more threads per image would only compete for the CPUs.
|
// bytes, so the operation cache would rarely be hit and would hold memory
|
||||||
// Each request decodes different source bytes, so the operation cache
|
// outside MaxConcurrentProcessing; repeated requests are served from
|
||||||
// would rarely be hit and would hold memory outside MaxConcurrentProcessing;
|
// 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,7 +178,7 @@ 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, the number of CPUs Go uses (runtime.GOMAXPROCS(0)) is used.
|
// If <= 0, config.DefaultMaxConcurrentProcessing() is used.
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +194,7 @@ func New(params Params) *ImageProcessor {
|
|||||||
|
|
||||||
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
maxConcurrentProcessing := params.MaxConcurrentProcessing
|
||||||
if maxConcurrentProcessing <= 0 {
|
if maxConcurrentProcessing <= 0 {
|
||||||
maxConcurrentProcessing = runtime.GOMAXPROCS(0)
|
maxConcurrentProcessing = config.DefaultMaxConcurrentProcessing()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ImageProcessor{
|
return &ImageProcessor{
|
||||||
|
|||||||
@@ -115,14 +115,14 @@ func waitForEntries(t *testing.T, entered <-chan struct{}, count int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewDefaultsMaxConcurrentProcessingToCPUs(t *testing.T) {
|
func TestNewDefaultsMaxConcurrentProcessingToHalfTheCPUs(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 != runtime.GOMAXPROCS(0) {
|
if got := cap(proc.processingSemaphore); got != max(runtime.GOMAXPROCS(0)/2, 1) {
|
||||||
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, one per CPU",
|
t.Errorf("MaxConcurrentProcessing %d: %d slots, want %d, half the CPUs",
|
||||||
limit, got, runtime.GOMAXPROCS(0))
|
limit, got, max(runtime.GOMAXPROCS(0)/2, 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +132,30 @@ func TestNewDefaultsMaxConcurrentProcessingToCPUs(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
|
||||||
|
|||||||
@@ -660,10 +660,10 @@ func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) err
|
|||||||
// hold: it reads the content type from the variant's .meta file and
|
// hold: it reads the content type from the variant's .meta file and
|
||||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||||
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
||||||
// as one can between a store's writing of the variant file and of its
|
// as one can when the store's .meta write failed or an eviction removed
|
||||||
// .meta file, serves application/octet-stream and keeps nothing, so
|
// the file after the read opened the variant, serves
|
||||||
// metaCache only ever holds a type read from a .meta file or passed to
|
// application/octet-stream and keeps nothing, so metaCache only ever
|
||||||
// StoreVariant.
|
// holds a type read from a .meta file or passed to StoreVariant.
|
||||||
func (c *Cache) loadVariantWithMeta(
|
func (c *Cache) loadVariantWithMeta(
|
||||||
cacheKey VariantKey,
|
cacheKey VariantKey,
|
||||||
) (io.ReadCloser, int64, string, error) {
|
) (io.ReadCloser, int64, string, error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
@@ -560,10 +561,11 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
|||||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
// insert under transient DB contention), drops accounting rows whose
|
// insert under transient DB contention), drops accounting rows whose
|
||||||
// files are missing, removes source blob files the database does not
|
// files are missing, removes source blob files the database does not
|
||||||
// know (and rows whose files are gone), sweeps stale temp files left
|
// know (and rows whose files are gone), removes source and variant files
|
||||||
// behind by crashed writes, and last checks the total cache usage
|
// that are empty or not the size their rows record, sweeps stale temp
|
||||||
// against the tables. It reads the tables a page at a time. Running it
|
// files left behind by crashed writes, and last checks the total cache
|
||||||
// periodically, not just once, bounds how long such drift can
|
// usage against the tables. It reads the tables a page at a time.
|
||||||
|
// Running it periodically, not just once, bounds how long such drift can
|
||||||
// accumulate unaccounted for on a long-running process to one eviction
|
// accumulate unaccounted for on a long-running process to one eviction
|
||||||
// interval. Once ctx is cancelled, it stops at the next file or row and
|
// interval. Once ctx is cancelled, it stops at the next file or row and
|
||||||
// returns ctx's error.
|
// returns ctx's error.
|
||||||
@@ -595,8 +597,9 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|||||||
return c.reconcileUsageTotal(ctx)
|
return c.reconcileUsageTotal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileVariantFiles walks the variant storage directory, adopting
|
// reconcileVariantFiles walks the variant storage directory, checking
|
||||||
// files without accounting rows and sweeping stale temp files.
|
// each variant file against its accounting row (reconcileVariantFile)
|
||||||
|
// and sweeping stale temp files.
|
||||||
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
||||||
return filepath.WalkDir(
|
return filepath.WalkDir(
|
||||||
c.variants.baseDir,
|
c.variants.baseDir,
|
||||||
@@ -621,38 +624,55 @@ func (c *Cache) reconcileVariantFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.adoptVariantFile(ctx, path, entry, VariantKey(name))
|
return c.reconcileVariantFile(ctx, path, entry, VariantKey(name))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptVariantFile inserts an accounting row for a variant file that
|
// reconcileVariantFile checks one variant file against its accounting
|
||||||
// has none, using the file's size and modification time.
|
// row. A file without a row is adopted into the accounting, unless it is
|
||||||
func (c *Cache) adoptVariantFile(
|
// empty. A file that is empty, or not the size its row records, as a
|
||||||
|
// crash or a restored snapshot can leave it, is removed with its row and
|
||||||
|
// a warning naming it, so the variant is converted again.
|
||||||
|
func (c *Cache) reconcileVariantFile(
|
||||||
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
ctx context.Context, path string, entry fs.DirEntry, cacheKey VariantKey,
|
||||||
) error {
|
) error {
|
||||||
var rowExists int
|
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
|
||||||
).Scan(&rowExists)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowExists > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := entry.Info()
|
info, err := entry.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to stat variant file: %w", err)
|
return fmt.Errorf("failed to stat variant file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var recordedSize int64
|
||||||
|
|
||||||
|
err = c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT size_bytes FROM variant_content WHERE cache_key = ?`, string(cacheKey),
|
||||||
|
).Scan(&recordedSize)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows) && info.Size() > 0:
|
||||||
|
return c.adoptVariantFile(ctx, path, info, cacheKey)
|
||||||
|
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
||||||
|
return fmt.Errorf("failed to check variant accounting row: %w", err)
|
||||||
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file is empty, or not the size its row records
|
||||||
|
c.log.Warn("removing damaged cached variant file", "path", path,
|
||||||
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
|
|
||||||
|
return c.evictVariant(ctx, cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adoptVariantFile inserts an accounting row for a variant file that
|
||||||
|
// has none, using the file's size and modification time.
|
||||||
|
func (c *Cache) adoptVariantFile(
|
||||||
|
ctx context.Context, path string, info fs.FileInfo, cacheKey VariantKey,
|
||||||
|
) error {
|
||||||
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
modTime := info.ModTime().UTC().Format(sqliteTimestampLayout)
|
||||||
contentType := c.variantContentTypeFromSidecar(path)
|
contentType := c.variantContentTypeFromSidecar(path)
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx, `
|
_, err := c.db.ExecContext(ctx, `
|
||||||
INSERT INTO variant_content
|
INSERT INTO variant_content
|
||||||
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
(cache_key, size_bytes, content_type, created_at, last_accessed_at)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
@@ -771,10 +791,9 @@ func queryStringColumn[T ~string](
|
|||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileSourceFiles walks the source content directory, removing
|
// reconcileSourceFiles walks the source content directory, checking
|
||||||
// blob files the database does not track (they are unreachable: source
|
// each blob file against its source_content row (reconcileSourceFile)
|
||||||
// lookups always go through source_metadata) and sweeping stale temp
|
// and sweeping stale temp files.
|
||||||
// files.
|
|
||||||
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
||||||
return filepath.WalkDir(
|
return filepath.WalkDir(
|
||||||
c.srcContent.baseDir,
|
c.srcContent.baseDir,
|
||||||
@@ -795,31 +814,55 @@ func (c *Cache) reconcileSourceFiles(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.removeUntrackedSourceFile(ctx, path, ContentHash(name))
|
return c.reconcileSourceFile(ctx, path, entry, ContentHash(name))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileSourceFile checks one source blob file against its
|
||||||
|
// source_content row. A file without a row is removed: it is
|
||||||
|
// unreachable, as source lookups always go through source_metadata. A
|
||||||
|
// file that is empty, or not the size its row records, as a crash or a
|
||||||
|
// restored snapshot can leave it, is removed with its rows and a warning
|
||||||
|
// naming it, so the source is fetched again.
|
||||||
|
func (c *Cache) reconcileSourceFile(
|
||||||
|
ctx context.Context, path string, entry fs.DirEntry, contentHash ContentHash,
|
||||||
|
) error {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat source content file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var recordedSize int64
|
||||||
|
|
||||||
|
err = c.db.QueryRowContext(ctx,
|
||||||
|
`SELECT size_bytes FROM source_content WHERE content_hash = ?`,
|
||||||
|
string(contentHash),
|
||||||
|
).Scan(&recordedSize)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
return c.removeUntrackedSourceFile(ctx, path, contentHash)
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("failed to check source content row: %w", err)
|
||||||
|
case info.Size() > 0 && info.Size() == recordedSize:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file is empty, or not the size its row records
|
||||||
|
c.log.Warn("removing damaged cached source file", "path", path,
|
||||||
|
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||||
|
|
||||||
|
return c.evictSourceBlob(ctx, contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
// removeUntrackedSourceFile deletes a source blob file that has no
|
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||||
// source_content row. Any source_metadata rows referencing the hash
|
// source_content row. Any source_metadata rows referencing the hash
|
||||||
// are removed first so no row ever points at a deleted file.
|
// are removed first so no row ever points at a deleted file.
|
||||||
func (c *Cache) removeUntrackedSourceFile(
|
func (c *Cache) removeUntrackedSourceFile(
|
||||||
ctx context.Context, path string, contentHash ContentHash,
|
ctx context.Context, path string, contentHash ContentHash,
|
||||||
) error {
|
) error {
|
||||||
var rowExists int
|
_, err := c.db.ExecContext(ctx,
|
||||||
|
|
||||||
err := c.db.QueryRowContext(ctx,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE content_hash = ?`, string(contentHash),
|
|
||||||
).Scan(&rowExists)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to check source content row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowExists > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.db.ExecContext(ctx,
|
|
||||||
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
`DELETE FROM source_metadata WHERE content_hash = ?`, string(contentHash))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
return fmt.Errorf("failed to delete metadata rows for untracked blob: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package imgcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getImage calls Get for req and returns the image it answered with and
|
||||||
|
// its cache status.
|
||||||
|
func getImage(t *testing.T, svc *Service, req *ImageRequest) ([]byte, CacheStatus) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
resp, err := svc.Get(t.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() of quality %d error = %v", req.Quality, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = resp.Content.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the image of quality %d: %v", req.Quality, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, resp.CacheStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileSize returns the size of the file at path.
|
||||||
|
func fileSize(t *testing.T, path string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateFile cuts the file at path down to size bytes.
|
||||||
|
func truncateFile(t *testing.T, path string, size int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Logf("cutting %s from %d to %d bytes",
|
||||||
|
filepath.Base(path), fileSize(t, path), size)
|
||||||
|
|
||||||
|
err := os.Truncate(path, size)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("truncating %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedSourcePath returns the path of the cached source file req uses.
|
||||||
|
func cachedSourcePath(t *testing.T, svc *Service, req *ImageRequest) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
|
||||||
|
if err != nil || contentHash == "" {
|
||||||
|
t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc.cache.srcContent.hashToPath(contentHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
||||||
|
// when it starts, and returns what the pass logged.
|
||||||
|
func reconcileAsAtStartup(t *testing.T, cache *Cache) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var logBuf bytes.Buffer
|
||||||
|
|
||||||
|
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
||||||
|
|
||||||
|
err := cache.reconcileAccounting(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reconcileAccounting() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("reconciliation log: %s", logBuf.String())
|
||||||
|
|
||||||
|
return logBuf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertWarningNames checks that logs hold a warning naming the file at
|
||||||
|
// path.
|
||||||
|
func assertWarningNames(t *testing.T, logs, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for line := range strings.Lines(logs) {
|
||||||
|
if strings.Contains(line, `"level":"WARN"`) &&
|
||||||
|
strings.Contains(line, `"path":"`+path+`"`) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Errorf("reconciliation logged no warning naming %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_FetchesAgainForEmptyCachedSource empties the cached source
|
||||||
|
// file, as a crash or a restored snapshot could, and runs the reconciliation
|
||||||
|
// pass, which must remove it with a warning naming it. It then asks for two
|
||||||
|
// new sizes of the source: the first must fetch it again and cache it whole,
|
||||||
|
// so that the second is made from the cache without a third fetch.
|
||||||
|
func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
|
truncateFile(t, path, 0)
|
||||||
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
||||||
|
|
||||||
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
|
if fetches != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2: the empty cached source "+
|
||||||
|
"was kept and fetched again on every new size", fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fileSize(t, path)
|
||||||
|
if size != wholeSize {
|
||||||
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_FetchesAgainForPartialCachedSource cuts the cached source
|
||||||
|
// file to half its size, as a crash or a restored snapshot could, and runs
|
||||||
|
// the reconciliation pass, which must remove it with a warning naming it.
|
||||||
|
// It then asks for two new sizes of the source: the first must fetch it
|
||||||
|
// again rather than fail to decode the partial file, and the second is
|
||||||
|
// made from the cache.
|
||||||
|
func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
|
||||||
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
||||||
|
wholeSize := fileSize(t, path)
|
||||||
|
|
||||||
|
truncateFile(t, path, wholeSize/2)
|
||||||
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
||||||
|
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
||||||
|
|
||||||
|
fetches := readCacheStatsCounters(t, svc.cache).upstreamFetchCount
|
||||||
|
t.Logf("upstream fetches = %d", fetches)
|
||||||
|
|
||||||
|
if fetches != 2 {
|
||||||
|
t.Errorf("upstream fetches = %d, want 2", fetches)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := fileSize(t, path)
|
||||||
|
if size != wholeSize {
|
||||||
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertConvertedAgain caches a variant of the photo, cuts its file to size
|
||||||
|
// bytes and runs the reconciliation pass, which must remove it with a
|
||||||
|
// warning naming it. It then checks that Get converts the image again
|
||||||
|
// instead of serving the file, and that the next Get serves the whole
|
||||||
|
// variant from the cache.
|
||||||
|
func assertConvertedAgain(t *testing.T, size int64) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
svc, fixtures := SetupTestService(t)
|
||||||
|
req := photoVariant(fixtures, 85, FitCover)
|
||||||
|
|
||||||
|
want, _ := getImage(t, svc, req)
|
||||||
|
|
||||||
|
path := svc.cache.variants.keyToPath(CacheKey(req))
|
||||||
|
|
||||||
|
truncateFile(t, path, size)
|
||||||
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
||||||
|
|
||||||
|
got, status := getImage(t, svc, req)
|
||||||
|
t.Logf("after the cut: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("after the cut: Get() = %d bytes, %s; want the %d bytes "+
|
||||||
|
"converted again, %s", len(got), status, len(want), CacheMiss)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, status = getImage(t, svc, req)
|
||||||
|
t.Logf("next request: %d bytes, %s", len(got), status)
|
||||||
|
|
||||||
|
if status != CacheHit || !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("next request: Get() = %d bytes, %s; want the %d bytes, %s",
|
||||||
|
len(got), status, len(want), CacheHit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ConvertsAgainForEmptyCachedVariant empties a cached
|
||||||
|
// variant file, as a crash or a restored snapshot could, and checks that it
|
||||||
|
// is converted again rather than served empty.
|
||||||
|
func TestService_Get_ConvertsAgainForEmptyCachedVariant(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertConvertedAgain(t, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestService_Get_ConvertsAgainForPartialCachedVariant cuts a cached
|
||||||
|
// variant file to ten bytes, as a crash or a restored snapshot could, and
|
||||||
|
// checks that it is converted again rather than served cut short.
|
||||||
|
func TestService_Get_ConvertsAgainForPartialCachedVariant(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assertConvertedAgain(t, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconciliationRemovesEmptyVariantFileWithoutRow checks that the
|
||||||
|
// reconciliation pass removes an empty variant file that has no
|
||||||
|
// accounting row, with a warning naming it, instead of adding a row for
|
||||||
|
// it that would have it served empty.
|
||||||
|
func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil),
|
||||||
|
testContentTypeWebP)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to store an empty variant file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := reconcileAsAtStartup(t, cache)
|
||||||
|
|
||||||
|
assertWarningNames(t, logs, cache.variants.keyToPath(testVariantKeyOne))
|
||||||
|
|
||||||
|
if cache.variants.Exists(testVariantKeyOne) {
|
||||||
|
t.Error("the empty variant file is still on disk")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := countRows(t, cache,
|
||||||
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`,
|
||||||
|
string(testVariantKeyOne))
|
||||||
|
if rows != 0 {
|
||||||
|
t.Errorf("the empty variant file got %d accounting rows, want 0", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVariantStorage_StoreReplacesMetaFile stores a variant over one
|
||||||
|
// already stored and checks that Store puts a new .meta file in place of
|
||||||
|
// the old one, as it does the image, rather than writing into the old
|
||||||
|
// file, which a crash could leave empty. The old .meta file is given a
|
||||||
|
// second name before the store, so that it can be told apart from the new
|
||||||
|
// one after it.
|
||||||
|
func TestVariantStorage_StoreReplacesMetaFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storage, key := storeTestVariant(t)
|
||||||
|
metaPath := storage.keyToPath(key) + variantMetaSuffix
|
||||||
|
oldMetaPath := metaPath + ".old"
|
||||||
|
|
||||||
|
err := os.Link(metaPath, oldMetaPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("giving the .meta file a second name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = storage.Store(key, bytes.NewReader([]byte("new variant data")),
|
||||||
|
testContentTypeJPEG)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Store() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldMeta, err := os.Stat(oldMetaPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat of the old .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMeta, err := os.Stat(metaPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stat of the new .meta file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.SameFile(oldMeta, newMeta) {
|
||||||
|
t.Error("Store() wrote into the old .meta file instead of putting " +
|
||||||
|
"a new one in its place")
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, contentType, err := storage.LoadWithMeta(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadWithMeta() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = reader.Close()
|
||||||
|
|
||||||
|
t.Logf("content type after the store: %s", contentType)
|
||||||
|
|
||||||
|
if contentType != testContentTypeJPEG {
|
||||||
|
t.Errorf("content type = %q, want %q", contentType, testContentTypeJPEG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContentStorage_StoreHashedReplacesFileOfAnotherSize checks that a
|
||||||
|
// store over a file of another size at the content's path, such as a
|
||||||
|
// partial file a crash left, replaces it with the content.
|
||||||
|
func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
storage, err := NewContentStorage(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := []byte("the whole source image")
|
||||||
|
sum := sha256.Sum256(content)
|
||||||
|
hash := ContentHash(hex.EncodeToString(sum[:]))
|
||||||
|
path := storage.hashToPath(hash)
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating the content's directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("writing a partial file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = storage.StoreHashed(hash, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StoreHashed() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := storage.Load(hash)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = stored.Close() }()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(stored)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading the stored content: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, content) {
|
||||||
|
t.Errorf("stored file = %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,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, one per CPU
|
// uses the image processor's default, half the CPUs
|
||||||
MaxConcurrentProcessing int
|
MaxConcurrentProcessing int
|
||||||
// Logger for logging
|
// Logger for logging
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ func (s *ContentStorage) Store(r io.Reader) (ContentHash, int64, error) {
|
|||||||
// from hash, without recomputing it. Callers that already know the
|
// from hash, without recomputing it. Callers that already know the
|
||||||
// hash before writing (e.g. because they must hold a hash-keyed lock
|
// hash before writing (e.g. because they must hold a hash-keyed lock
|
||||||
// across the whole store operation) use this instead of Store. Like
|
// across the whole store operation) use this instead of Store. Like
|
||||||
// Store, it is idempotent: content already on disk at that path is
|
// Store, it is idempotent: a file of the content's size already at that
|
||||||
// left untouched.
|
// path is left untouched, and a file of another size is replaced.
|
||||||
func (s *ContentStorage) StoreHashed(
|
func (s *ContentStorage) StoreHashed(
|
||||||
hash ContentHash, data []byte,
|
hash ContentHash, data []byte,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
@@ -152,28 +152,35 @@ func (s *ContentStorage) Exists(hash ContentHash) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeIfAbsent writes data to the path derived from hash, unless
|
// writeIfAbsent writes data with writeFileSynced to the path derived
|
||||||
// content already exists there, via a temp-file-plus-rename so
|
// from hash, unless a file of the data's size is already there.
|
||||||
// concurrent readers never observe a partial file.
|
|
||||||
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
||||||
// Build path: <basedir>/<ab>/<cd>/<hash>
|
// Build path: <basedir>/<ab>/<cd>/<hash>
|
||||||
path := s.hashToPath(hash)
|
path := s.hashToPath(hash)
|
||||||
|
|
||||||
// Check if already exists
|
// A file of another size there is damaged, and is replaced
|
||||||
_, statErr := os.Stat(path)
|
info, statErr := os.Stat(path)
|
||||||
if statErr == nil {
|
if statErr == nil && info.Size() == int64(len(data)) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory structure
|
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
err := os.MkdirAll(dir, StorageDirPerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
return fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to temp file first, then rename for atomicity
|
return writeFileSynced(path, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileSynced writes data to path through a temp file in the same
|
||||||
|
// directory: the temp file is synced to disk, renamed to path, and the
|
||||||
|
// directory synced after, so that neither a concurrent reader nor a
|
||||||
|
// crash finds a partial file under path. When the directory sync fails,
|
||||||
|
// the file is removed from path, so that a failed write leaves no file
|
||||||
|
// there.
|
||||||
|
func writeFileSynced(path string, data []byte) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temp file: %w", err)
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
@@ -181,18 +188,20 @@ func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
tmpPath := tmpFile.Name()
|
||||||
|
|
||||||
// Each failure path below unlinks the temp file explicitly. This
|
|
||||||
// replaces a deferred cleanup that read a named result, which the
|
|
||||||
// canonical config does not permit; the set of paths that remove
|
|
||||||
// tmpPath, and the order relative to Close, is unchanged. This
|
|
||||||
// mirrors how MetadataStorage.Store and VariantStorage.Store below
|
|
||||||
// already express the same cleanup.
|
|
||||||
_, err = tmpFile.Write(data)
|
_, err = tmpFile.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tmpFile.Close()
|
_ = tmpFile.Close()
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
return fmt.Errorf("failed to write content: %w", err)
|
return fmt.Errorf("failed to write temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tmpFile.Sync()
|
||||||
|
if err != nil {
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to sync temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tmpFile.Close()
|
err = tmpFile.Close()
|
||||||
@@ -202,7 +211,6 @@ func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|||||||
return fmt.Errorf("failed to close temp file: %w", err)
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename
|
|
||||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(tmpPath)
|
_ = os.Remove(tmpPath)
|
||||||
@@ -210,6 +218,31 @@ func (s *ContentStorage) writeIfAbsent(hash ContentHash, data []byte) error {
|
|||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = syncDirectory(dir)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDirectory syncs the directory dir to disk, so that a file just
|
||||||
|
// renamed into it is still there after a crash.
|
||||||
|
func syncDirectory(dir string) error {
|
||||||
|
d, err := os.Open(filepath.Clean(dir))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = d.Sync()
|
||||||
|
_ = d.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sync directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +460,9 @@ func NewVariantStorage(baseDir string, log *slog.Logger) (*VariantStorage, error
|
|||||||
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
return &VariantStorage{baseDir: baseDir, log: log}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store writes content and metadata to storage at the given key.
|
// Store writes content and metadata to storage at the given key, each
|
||||||
|
// with writeFileSynced. The .meta file is written first, so that a crash
|
||||||
|
// cannot leave the content in place without it.
|
||||||
func (s *VariantStorage) Store(
|
func (s *VariantStorage) Store(
|
||||||
key VariantKey, r io.Reader, contentType string,
|
key VariantKey, r io.Reader, contentType string,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
@@ -440,46 +475,11 @@ func (s *VariantStorage) Store(
|
|||||||
path := s.keyToPath(key)
|
path := s.keyToPath(key)
|
||||||
metaPath := path + ".meta"
|
metaPath := path + ".meta"
|
||||||
|
|
||||||
// Create directory structure
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
dir := filepath.Dir(path)
|
|
||||||
|
|
||||||
err = os.MkdirAll(dir, StorageDirPerm)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to create directory: %w", err)
|
return 0, fmt.Errorf("failed to create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write content to temp file first, then rename for atomicity
|
|
||||||
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to create temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpPath := tmpFile.Name()
|
|
||||||
|
|
||||||
_, err = tmpFile.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
_ = tmpFile.Close()
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to write content: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tmpFile.Close()
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to close temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic rename content
|
|
||||||
err = os.Rename(filepath.Clean(tmpPath), filepath.Clean(path))
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmpPath)
|
|
||||||
|
|
||||||
return 0, fmt.Errorf("failed to rename temp file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write metadata
|
|
||||||
meta := VariantMeta{
|
meta := VariantMeta{
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Size: size,
|
Size: size,
|
||||||
@@ -491,13 +491,19 @@ func (s *VariantStorage) Store(
|
|||||||
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
return 0, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metadata write failure is non-fatal; content is already stored.
|
// Metadata write failure is non-fatal: the content is then served as
|
||||||
err = os.WriteFile(metaPath, metaData, StorageFilePerm)
|
// application/octet-stream.
|
||||||
|
err = writeFileSynced(metaPath, metaData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("failed to write variant metadata sidecar",
|
s.log.Warn("failed to write variant metadata sidecar",
|
||||||
"path", metaPath, "error", err)
|
"path", metaPath, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = writeFileSynced(path, data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
return size, nil
|
return size, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user