Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422b7c020b | ||
|
|
fd7d7ed205 | ||
|
|
bce8860c2e |
@@ -40,9 +40,8 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||
|
||||
- **Port:** pixa listens on container port `8080`.
|
||||
- **Volume:** container path `/var/lib/pixa`, where pixa keeps its
|
||||
database and cache. upaas bind-mounts the host path it is given and
|
||||
does not create it, so the host directory must exist before the first
|
||||
deploy.
|
||||
database and cache. Creating the host directory when it is missing is
|
||||
upaas's job, tracked in https://git.eeqj.de/sneak/upaas/issues/235.
|
||||
- **Environment variables:**
|
||||
- `PIXA_SIGNING_KEY` (required): secret for signed and encrypted URLs
|
||||
and login, 32+ characters, for example from
|
||||
@@ -58,10 +57,6 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||
`healthy`. The probe uses the port from `PORT` (default `8080`), so a
|
||||
port changed only in a mounted config file is not seen by it: change
|
||||
the port with `PORT`.
|
||||
- **First run:** create the host directory, owned by root or by uid
|
||||
`65532` and gid `65532`. The server runs as the container's `pixad`
|
||||
user, which has that uid and gid, and the container gives the
|
||||
directory to `pixad` when it starts.
|
||||
|
||||
## Rationale
|
||||
|
||||
@@ -86,7 +81,10 @@ prevent abuse, and allowlisted source hosts for open access.
|
||||
|
||||
Multiple source paths may reference the same content blob; the
|
||||
database tracks references rather than using filesystem refcounting.
|
||||
In-process caching of request-to-output mappings targets 1-5k r/s.
|
||||
Toward a target of 1-5k r/s, 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 reads only the image file from disk and not
|
||||
the metadata file stored beside it.
|
||||
|
||||
### Routes
|
||||
|
||||
|
||||
@@ -29,6 +29,24 @@ P2: security: referer blacklist
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
|
||||
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
|
||||
gives the directory and everything in it to `pixad` when the directory or one
|
||||
of its top-level entries belongs to another user or group, sets its mode to
|
||||
`750`, then runs the server as `pixad`; data left by an earlier run under
|
||||
another uid is taken over this way; "Running under upaas" in `README.md` no
|
||||
longer tells the operator to create or chown the host directory.
|
||||
- 2026-09-29 variant content types kept in memory (closes #70):
|
||||
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
|
||||
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
|
||||
`GetVariant` after it reads a `.meta` file, where a type `StoreVariant` added
|
||||
meanwhile is kept over the one read, and never with the
|
||||
`application/octet-stream` served for a variant without one; for a variant it
|
||||
holds, `GetVariant` skips the `.meta` read, still opening the variant file and
|
||||
taking the size from it; eviction removes the entry before deleting the files,
|
||||
and `GetVariant` removes it when the file will not open; the cap is a
|
||||
constant, not a setting; the unused `variantMeta` type is gone; `README.md`
|
||||
describes it.
|
||||
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
|
||||
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
|
||||
`Retry-After` header and the JSON error body, from one middleware in
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx"
|
||||
)
|
||||
|
||||
// errTestHook is the error returned by the test hooks that fail.
|
||||
var errTestHook = errors.New("test hook failed")
|
||||
|
||||
// TestRunAppExitCode checks the exit code runApp returns: the one a
|
||||
// shutdown request carries, 0 for a request without one (as for SIGINT or
|
||||
// SIGTERM), and 1 when the app fails to start or to stop.
|
||||
func TestRunAppExitCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hook func(shutdowner fx.Shutdowner) fx.Hook
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "shutdown requested with exit code 1",
|
||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||
return fx.StartHook(func() error {
|
||||
return shutdowner.Shutdown(fx.ExitCode(1))
|
||||
})
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "shutdown requested without an exit code",
|
||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||
return fx.StartHook(func() error {
|
||||
return shutdowner.Shutdown()
|
||||
})
|
||||
},
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "start fails",
|
||||
hook: func(fx.Shutdowner) fx.Hook {
|
||||
return fx.StartHook(func() error { return errTestHook })
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "stop fails",
|
||||
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||
return fx.StartStopHook(
|
||||
func() error { return shutdowner.Shutdown() },
|
||||
func() error { return errTestHook },
|
||||
)
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Invoke(func(lc fx.Lifecycle, shutdowner fx.Shutdowner) {
|
||||
lc.Append(tc.hook(shutdowner))
|
||||
}),
|
||||
)
|
||||
|
||||
got := runApp(app)
|
||||
t.Logf("runApp() = %d", got)
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("runApp() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
#!/bin/sh
|
||||
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
|
||||
# root only to give /var/lib/pixa to pixad: a host directory
|
||||
# bind-mounted there keeps its host owner, often root, and pixad could
|
||||
# not write to it. The server itself always runs as pixad.
|
||||
# root only to make /var/lib/pixa usable by pixad: a host directory
|
||||
# bind-mounted there keeps its host owner, often root, and data from an
|
||||
# earlier run may belong to another uid. The server itself always runs
|
||||
# as pixad.
|
||||
set -eu
|
||||
|
||||
main() {
|
||||
if [ "$(stat -c %U /var/lib/pixa)" != pixad ]; then
|
||||
chown pixad:pixad /var/lib/pixa
|
||||
mkdir -p /var/lib/pixa
|
||||
# Only the directory and its top-level entries are checked, so a
|
||||
# normal start does not walk the cache. -depth gives each directory
|
||||
# to pixad after its contents, so a start stopped part way leaves
|
||||
# something at the top for the next start to find; -h changes a
|
||||
# symlink itself, never the file it points to.
|
||||
if [ -n "$(find /var/lib/pixa -maxdepth 1 \( ! -user pixad -o ! -group pixad \))" ]; then
|
||||
find /var/lib/pixa -depth -exec chown -h pixad:pixad {} +
|
||||
fi
|
||||
chmod 750 /var/lib/pixa
|
||||
exec su-exec pixad /usr/local/bin/pixad "$@"
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/go-chi/httprate v0.16.0
|
||||
github.com/gorilla/csrf v1.7.3
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/slok/go-http-metrics v0.13.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
|
||||
@@ -228,6 +228,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
|
||||
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
|
||||
@@ -300,3 +300,69 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForProcessing holds a processing slot with a Process call that
|
||||
// cannot finish reading its input. WaitForProcessing must report that image
|
||||
// when its context ends first, wait for it otherwise, return 0 once it has
|
||||
// finished, and give back the slots it took while waiting.
|
||||
func TestWaitForProcessing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
proc := New(Params{MaxConcurrentProcessing: 2})
|
||||
|
||||
gate := make(chan struct{})
|
||||
entered := make(chan struct{}, 1)
|
||||
results := make(chan error, 1)
|
||||
|
||||
openGate := sync.OnceFunc(func() { close(gate) })
|
||||
t.Cleanup(openGate)
|
||||
|
||||
processInBackground(proc, &gatedReader{
|
||||
data: bytes.NewReader(createTestJPEG(t, 10, 10)), gate: gate,
|
||||
entered: entered, counter: &readingCounter{},
|
||||
}, results)
|
||||
waitForEntries(t, entered, 1)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
stillProcessing := proc.WaitForProcessing(ctx)
|
||||
t.Logf("WaitForProcessing() after its context ended: %d", stillProcessing)
|
||||
|
||||
if stillProcessing != 1 {
|
||||
t.Errorf("WaitForProcessing() after its context ended = %d, want 1",
|
||||
stillProcessing)
|
||||
}
|
||||
|
||||
waited := make(chan int, 1)
|
||||
|
||||
go func() { waited <- proc.WaitForProcessing(t.Context()) }()
|
||||
|
||||
select {
|
||||
case got := <-waited:
|
||||
t.Fatalf("WaitForProcessing() = %d while an image was being processed",
|
||||
got)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
openGate()
|
||||
|
||||
err := <-results
|
||||
if err != nil {
|
||||
t.Errorf("Process() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-waited:
|
||||
if got != 0 {
|
||||
t.Errorf("WaitForProcessing() once processing finished = %d, want 0",
|
||||
got)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("WaitForProcessing() did not return once processing finished")
|
||||
}
|
||||
|
||||
if held := len(proc.processingSemaphore); held != 0 {
|
||||
t.Errorf("%d slots still held after WaitForProcessing() returned", held)
|
||||
}
|
||||
}
|
||||
|
||||
+61
-12
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
)
|
||||
|
||||
@@ -26,6 +27,10 @@ var (
|
||||
// HTTP status code for successful fetch.
|
||||
const httpStatusOK = 200
|
||||
|
||||
// metaCacheSize is how many variants' content types metaCache holds. A
|
||||
// variant not among them is served as before, reading its .meta file.
|
||||
const metaCacheSize = 10000
|
||||
|
||||
// CacheConfig holds cache configuration.
|
||||
type CacheConfig struct {
|
||||
StateDir string
|
||||
@@ -49,12 +54,6 @@ type CacheConfig struct {
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// variantMeta stores content type for fast cache hits without reading .meta file.
|
||||
type variantMeta struct {
|
||||
ContentType string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Cache implements the caching layer for the image proxy.
|
||||
type Cache struct {
|
||||
db *sql.DB
|
||||
@@ -76,9 +75,10 @@ type Cache struct {
|
||||
evictionStarted bool
|
||||
evictionStopOnce sync.Once
|
||||
|
||||
// In-memory cache of variant metadata (content type, size) to avoid
|
||||
// reading .meta files
|
||||
metaCache map[VariantKey]variantMeta
|
||||
// metaCache holds the content types of the variants most recently
|
||||
// stored or served, so a hit does not read the variant's .meta file.
|
||||
// It never stands in for the variant file, which is always opened.
|
||||
metaCache *lru.Cache[VariantKey, string]
|
||||
|
||||
// contentLocks serializes StoreSource and evictSourceBlob per
|
||||
// content hash, closing the race window between an eviction's row
|
||||
@@ -101,6 +101,11 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
metaCache, err := lru.New[VariantKey, string](metaCacheSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
|
||||
}
|
||||
|
||||
c := &Cache{
|
||||
db: db,
|
||||
config: config,
|
||||
@@ -109,7 +114,7 @@ func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||
evictionPressure: make(chan struct{}, 1),
|
||||
evictionStop: make(chan struct{}),
|
||||
evictionDone: make(chan struct{}),
|
||||
metaCache: make(map[VariantKey]variantMeta),
|
||||
metaCache: metaCache,
|
||||
contentLocks: newContentLock(),
|
||||
}
|
||||
|
||||
@@ -177,13 +182,30 @@ func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, e
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetVariant returns a reader, size, and content type for a cached variant.
|
||||
// GetVariant returns a reader, size, and content type for a cached
|
||||
// variant. The content type comes from metaCache, or else from the
|
||||
// variant's .meta file and is then kept in metaCache. A variant with
|
||||
// no .meta file is served as application/octet-stream, which is not
|
||||
// kept.
|
||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||
if c.disabled {
|
||||
return nil, 0, "", ErrNotFound
|
||||
}
|
||||
|
||||
return c.variants.LoadWithMeta(cacheKey)
|
||||
contentType, known := c.metaCache.Get(cacheKey)
|
||||
if !known {
|
||||
return c.loadVariantWithMeta(cacheKey)
|
||||
}
|
||||
|
||||
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
||||
if err != nil {
|
||||
// The file is gone, e.g. deleted outside pixa
|
||||
c.metaCache.Remove(cacheKey)
|
||||
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
return reader, size, contentType, nil
|
||||
}
|
||||
|
||||
// StoreSource stores fetched source content and metadata. On a
|
||||
@@ -286,6 +308,8 @@ func (c *Cache) StoreVariant(
|
||||
return err
|
||||
}
|
||||
|
||||
c.metaCache.Add(cacheKey, contentType)
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||
VALUES (?, ?, ?)
|
||||
@@ -499,6 +523,31 @@ func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||
// hold: it reads the content type from the variant's .meta file and
|
||||
// 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,
|
||||
// as one can between a store's writing of the variant file and of its
|
||||
// .meta file, serves application/octet-stream and keeps nothing, so
|
||||
// metaCache only ever holds a type read from a .meta file or passed to
|
||||
// StoreVariant.
|
||||
func (c *Cache) loadVariantWithMeta(
|
||||
cacheKey VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
if contentType == "" {
|
||||
return reader, size, fallbackContentType, nil
|
||||
}
|
||||
|
||||
c.metaCache.ContainsOrAdd(cacheKey, contentType)
|
||||
|
||||
return reader, size, contentType, nil
|
||||
}
|
||||
|
||||
// writeMetadataSidecar writes the JSON metadata sidecar of a stored source.
|
||||
// A failure is logged and is otherwise non-fatal; the metadata is in the
|
||||
// database.
|
||||
|
||||
@@ -39,8 +39,8 @@ const tempFilePrefix = ".tmp-"
|
||||
// to each variant file.
|
||||
const variantMetaSuffix = ".meta"
|
||||
|
||||
// fallbackContentType is recorded when a reconciled variant file has
|
||||
// no readable .meta sidecar.
|
||||
// fallbackContentType is the content type given to a variant file that
|
||||
// has no readable .meta sidecar, when it is served or reconciled.
|
||||
const fallbackContentType = "application/octet-stream"
|
||||
|
||||
// UsageBytes returns the total number of bytes of cache content
|
||||
@@ -271,7 +271,9 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
||||
|
||||
// evictVariant removes one variant: accounting row first, then the
|
||||
// content and .meta files, so the database never references a deleted
|
||||
// file.
|
||||
// file. The metaCache entry goes before the files; a GetVariant that
|
||||
// read them just before may put it back, and the next GetVariant then
|
||||
// fails to open the file and removes it again.
|
||||
func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||
@@ -279,6 +281,8 @@ func (c *Cache) evictVariant(ctx context.Context, cacheKey VariantKey) error {
|
||||
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||
}
|
||||
|
||||
c.metaCache.Remove(cacheKey)
|
||||
|
||||
err = c.variants.DeleteWithMeta(cacheKey)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// webpRequest returns a request for a 100x100 WebP variant of path.
|
||||
func webpRequest(path string) *ImageRequest {
|
||||
return &ImageRequest{
|
||||
SourceHost: testHostCDN,
|
||||
SourcePath: path,
|
||||
Size: Size{Width: 100, Height: 100},
|
||||
Format: FormatWebP,
|
||||
Quality: 85,
|
||||
FitMode: FitCover,
|
||||
}
|
||||
}
|
||||
|
||||
// assertVariantServed checks that GetVariant serves key with the given
|
||||
// content and the image/webp content type storeEvictionTestVariant stores.
|
||||
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) {
|
||||
t.Helper()
|
||||
|
||||
reader, size, contentType, err := cache.GetVariant(key)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVariant(%s) error = %v", key, err)
|
||||
}
|
||||
|
||||
defer func() { _ = reader.Close() }()
|
||||
|
||||
got, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("reading variant %s: %v", key, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
|
||||
}
|
||||
|
||||
if size != int64(len(content)) {
|
||||
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
|
||||
}
|
||||
|
||||
if contentType != testContentTypeWebP {
|
||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
||||
key, contentType, testContentTypeWebP)
|
||||
}
|
||||
}
|
||||
|
||||
// assertVariantNotFound checks that GetVariant refuses key with
|
||||
// ErrNotFound.
|
||||
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
|
||||
t.Helper()
|
||||
|
||||
reader, _, _, err := cache.GetVariant(key)
|
||||
if err == nil {
|
||||
_ = reader.Close()
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertLookupMisses checks that Lookup reports request as a miss.
|
||||
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) {
|
||||
t.Helper()
|
||||
|
||||
lookup, err := cache.Lookup(t.Context(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err)
|
||||
}
|
||||
|
||||
if lookup.Hit {
|
||||
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
// renameFile renames the file at from to to.
|
||||
func renameFile(t *testing.T, from, to string) {
|
||||
t.Helper()
|
||||
|
||||
err := os.Rename(from, to)
|
||||
if err != nil {
|
||||
t.Fatalf("renaming %s: %v", from, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
|
||||
// stored or read, a hit takes its content type from memory: with the
|
||||
// .meta file deleted, GetVariant must still return the stored content
|
||||
// type rather than the application/octet-stream it uses without one.
|
||||
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
content := []byte("webp variant bytes")
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||
|
||||
// A second Cache on the same state directory starts with nothing in
|
||||
// memory, as pixad does after a restart, so its first read uses the
|
||||
// .meta file.
|
||||
restarted, err := NewCache(cache.db, cache.config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache() error = %v", err)
|
||||
}
|
||||
|
||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
||||
|
||||
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
||||
if err != nil {
|
||||
t.Fatalf("removing .meta file: %v", err)
|
||||
}
|
||||
|
||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
||||
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
||||
}
|
||||
|
||||
// TestReadDuringStoreKeepsStoredContentType checks that a GetVariant
|
||||
// which began before StoreVariant finished cannot replace the content
|
||||
// type the store kept in memory. Such a read can find the variant file
|
||||
// but not yet its .meta file, and so gets application/octet-stream. The
|
||||
// test deletes the .meta file after the store, then runs the part of
|
||||
// GetVariant that comes after its check of memory.
|
||||
func TestReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
content := []byte("webp variant bytes")
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||
|
||||
err := os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
||||
if err != nil {
|
||||
t.Fatalf("removing .meta file: %v", err)
|
||||
}
|
||||
|
||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
||||
if err != nil {
|
||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
||||
|
||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
||||
}
|
||||
|
||||
// TestReadOfOlderMetaFileKeepsStoredContentType checks that a read
|
||||
// which got its content type from a .meta file that StoreVariant had
|
||||
// not yet rewritten cannot replace the type the store kept in memory.
|
||||
// The test writes such a .meta file, with a different content type,
|
||||
// after the store, then runs the part of GetVariant that comes after
|
||||
// its check of memory.
|
||||
func TestReadOfOlderMetaFileKeepsStoredContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
content := []byte("webp variant bytes")
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||
|
||||
olderMeta, err := json.Marshal(VariantMeta{
|
||||
ContentType: testContentTypeJPEG,
|
||||
Size: int64(len(content)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encoding .meta file: %v", err)
|
||||
}
|
||||
|
||||
metaPath := cache.variants.keyToPath(testVariantKeyOne) + ".meta"
|
||||
|
||||
err = os.WriteFile(metaPath, olderMeta, StorageFilePerm)
|
||||
if err != nil {
|
||||
t.Fatalf("writing .meta file: %v", err)
|
||||
}
|
||||
|
||||
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
||||
if err != nil {
|
||||
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
if contentType != testContentTypeJPEG {
|
||||
t.Fatalf("loadVariantWithMeta(%s) content type = %q, want %q from the .meta file",
|
||||
testVariantKeyOne, contentType, testContentTypeJPEG)
|
||||
}
|
||||
|
||||
kept, _ := cache.metaCache.Get(testVariantKeyOne)
|
||||
if kept != testContentTypeWebP {
|
||||
t.Errorf("content type in memory = %q, want the stored %q",
|
||||
kept, testContentTypeWebP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedReadDuringStoreKeepsStoredContentType checks that a read
|
||||
// which found no .meta file cannot leave application/octet-stream in
|
||||
// memory, even when another read has removed the content type
|
||||
// StoreVariant kept there. In this order: the store; a read that finds
|
||||
// the variant in memory but cannot open its file, and so removes it
|
||||
// from memory; a read that opened the variant file before the store
|
||||
// wrote its .meta file. Later hits must get the stored content type.
|
||||
func TestFailedReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
content := []byte("webp variant bytes")
|
||||
variantPath := cache.variants.keyToPath(testVariantKeyOne)
|
||||
metaPath := variantPath + ".meta"
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
||||
|
||||
renameFile(t, variantPath, variantPath+".hidden")
|
||||
assertVariantNotFound(t, cache, testVariantKeyOne)
|
||||
renameFile(t, variantPath+".hidden", variantPath)
|
||||
|
||||
renameFile(t, metaPath, metaPath+".hidden")
|
||||
|
||||
reader, _, contentType, err := cache.GetVariant(testVariantKeyOne)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVariant(%s) error = %v", testVariantKeyOne, err)
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
t.Logf("the read without a .meta file got content type %q", contentType)
|
||||
|
||||
renameFile(t, metaPath+".hidden", metaPath)
|
||||
|
||||
assertVariantServed(t, cache, testVariantKeyOne, content)
|
||||
}
|
||||
|
||||
// TestEvictedVariantIsNotServed checks that a variant the evictor
|
||||
// removed is a miss and cannot be read, although it had been stored
|
||||
// and served before.
|
||||
func TestEvictedVariantIsNotServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1500)
|
||||
|
||||
oldRequest := webpRequest("/old.jpg")
|
||||
newRequest := webpRequest("/new.jpg")
|
||||
oldKey := CacheKey(oldRequest)
|
||||
newKey := CacheKey(newRequest)
|
||||
oldContent := bytes.Repeat([]byte{0x01}, 1000)
|
||||
newContent := bytes.Repeat([]byte{0x02}, 1000)
|
||||
|
||||
storeEvictionTestVariant(t, cache, oldKey, oldContent)
|
||||
storeEvictionTestVariant(t, cache, newKey, newContent)
|
||||
assertVariantServed(t, cache, oldKey, oldContent)
|
||||
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
|
||||
|
||||
err := cache.EvictToLimit(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("EvictToLimit() error = %v", err)
|
||||
}
|
||||
|
||||
assertLookupMisses(t, cache, oldRequest)
|
||||
assertVariantNotFound(t, cache, oldKey)
|
||||
assertVariantServed(t, cache, newKey, newContent)
|
||||
}
|
||||
|
||||
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
|
||||
// file was deleted by something other than the evictor cannot be read,
|
||||
// and is a miss afterwards.
|
||||
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
|
||||
request := webpRequest("/deleted.jpg")
|
||||
key := CacheKey(request)
|
||||
content := []byte("webp variant bytes")
|
||||
|
||||
storeEvictionTestVariant(t, cache, key, content)
|
||||
assertVariantServed(t, cache, key, content)
|
||||
|
||||
err := os.Remove(cache.variants.keyToPath(key))
|
||||
if err != nil {
|
||||
t.Fatalf("removing variant file: %v", err)
|
||||
}
|
||||
|
||||
assertVariantNotFound(t, cache, key)
|
||||
assertLookupMisses(t, cache, request)
|
||||
}
|
||||
|
||||
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
|
||||
// variants from several goroutines at once, for the race detector.
|
||||
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<20)
|
||||
ctx := t.Context()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for goroutine := range 8 {
|
||||
wg.Go(func() {
|
||||
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
|
||||
content := []byte(key)
|
||||
|
||||
for range 20 {
|
||||
err := cache.StoreVariant(
|
||||
ctx, key, bytes.NewReader(content), testContentTypeWebP)
|
||||
if err != nil {
|
||||
t.Errorf("StoreVariant(%s) error = %v", key, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
reader, _, contentType, err := cache.GetVariant(key)
|
||||
if err != nil {
|
||||
t.Errorf("GetVariant(%s) error = %v", key, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
if contentType != testContentTypeWebP {
|
||||
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
||||
key, contentType, testContentTypeWebP)
|
||||
}
|
||||
|
||||
err = cache.evictVariant(ctx, key)
|
||||
if err != nil {
|
||||
t.Errorf("evictVariant(%s) error = %v", key, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assertVariantNotFound(t, cache, key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -506,32 +506,44 @@ func (s *VariantStorage) Load(key VariantKey) (io.ReadCloser, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||
// the given key.
|
||||
func (s *VariantStorage) LoadWithMeta(
|
||||
key VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
// LoadWithSize returns a reader and file size for the content at the
|
||||
// given key.
|
||||
func (s *VariantStorage) LoadWithSize(key VariantKey) (io.ReadCloser, int64, error) {
|
||||
path := s.keyToPath(key)
|
||||
metaPath := path + ".meta"
|
||||
|
||||
f, err := os.Open(path) //nolint:gosec // path derived from cache key
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, 0, "", ErrNotFound
|
||||
return nil, 0, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, 0, "", fmt.Errorf("failed to open content: %w", err)
|
||||
return nil, 0, fmt.Errorf("failed to open content: %w", err)
|
||||
}
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
|
||||
return nil, 0, "", fmt.Errorf("failed to stat content: %w", err)
|
||||
return nil, 0, fmt.Errorf("failed to stat content: %w", err)
|
||||
}
|
||||
|
||||
// Load metadata for content type
|
||||
contentType := "application/octet-stream" // fallback
|
||||
return f, stat.Size(), nil
|
||||
}
|
||||
|
||||
// LoadWithMeta returns a reader, size, and content type for the content at
|
||||
// the given key. The content type is read from the .meta file, and is
|
||||
// empty when that file is missing or unreadable.
|
||||
func (s *VariantStorage) LoadWithMeta(
|
||||
key VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
f, size, err := s.LoadWithSize(key)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
var contentType string
|
||||
|
||||
metaPath := s.keyToPath(key) + ".meta"
|
||||
|
||||
metaData, err := os.ReadFile(metaPath) //nolint:gosec // path derived from cache key
|
||||
if err == nil {
|
||||
@@ -541,7 +553,7 @@ func (s *VariantStorage) LoadWithMeta(
|
||||
}
|
||||
}
|
||||
|
||||
return f, stat.Size(), contentType, nil
|
||||
return f, size, contentType, nil
|
||||
}
|
||||
|
||||
// Exists checks if content exists at the given key.
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
testHostExample = "example.com"
|
||||
testPathCat = "/photos/cat.jpg"
|
||||
testContentTypeJPEG = "image/jpeg"
|
||||
testContentTypeWebP = "image/webp"
|
||||
testHeaderContentType = "Content-Type"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/globals"
|
||||
"sneak.berlin/go/pixa/internal/logger"
|
||||
)
|
||||
|
||||
// shutdownRecorder is an fx.Shutdowner that sends the options of each
|
||||
// shutdown request on requests.
|
||||
type shutdownRecorder struct {
|
||||
requests chan []fx.ShutdownOption
|
||||
}
|
||||
|
||||
func (r shutdownRecorder) Shutdown(opts ...fx.ShutdownOption) error {
|
||||
r.requests <- opts
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestSentryInitFailureFailsStartup checks that a Sentry DSN that cannot be
|
||||
// used makes the server's start hook fail, so fx stops what has already
|
||||
// started, instead of the process exiting from a goroutine.
|
||||
func TestSentryInitFailureFailsStartup(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lc := fxtest.NewLifecycle(t)
|
||||
|
||||
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = New(lc, Params{
|
||||
Logger: log,
|
||||
Globals: &globals.Globals{Appname: "pixad"},
|
||||
Config: &config.Config{SentryDSN: "not-a-dsn"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
err = lc.Start(t.Context())
|
||||
t.Logf("Start() error = %v", err)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Start() error = nil, want the Sentry initialization error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListenErrorRequestsShutdownWithExitCode1 occupies the server's port
|
||||
// and checks that the listen error asks fx to shut down with exit code 1.
|
||||
func TestListenErrorRequestsShutdownWithExitCode1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
busy, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatalf("Listen() error = %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = busy.Close() })
|
||||
|
||||
addr, ok := busy.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("listener address %v is not a TCP address", busy.Addr())
|
||||
}
|
||||
|
||||
requests := make(chan []fx.ShutdownOption, 1)
|
||||
s := &Server{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
config: &config.Config{Port: addr.Port},
|
||||
shutdowner: shutdownRecorder{requests: requests},
|
||||
}
|
||||
s.httpServer = s.newHTTPServer()
|
||||
|
||||
go s.serveUntilShutdown()
|
||||
|
||||
select {
|
||||
case opts := <-requests:
|
||||
t.Logf("shutdown options = %v", opts)
|
||||
|
||||
if len(opts) != 1 || opts[0] != fx.ExitCode(1) {
|
||||
t.Errorf("shutdown options = %v, want [fx.ExitCode(1)]", opts)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("no shutdown was requested after the listen error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user