Split internal/imgcache into the image proxy and the cache (closes #317)
check / check (push) Waiting to run
check / check (push) Waiting to run
The new package internal/imageproxy holds Proxy, which was imgcache.Service, with its Config, ImageResponse, CacheStatus and errors. imageproxy.New makes and registers the counters of upstream fetches and transforms. internal/imgcache holds the cache only, with its hit and miss counters, and imgcache.CacheConfig is imgcache.Config. The cache reads and writes each kind it keeps with one pair of methods: Variant and PutVariant, Source and PutSource, Failure and PutFailure. Source replaces LookupSource followed by GetSourceContent. imgcache.go and module.go are gone. The proxy's tests moved with it. The cache's tests check that the reconciliation pass removes an empty or cut file and its row. The proxy's tests remove them by hand, then check that Get fetches or converts again. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// TestProxy_Get_MakesAgainVariantOfNoOutputFormat replaces a cached
|
||||
// variant's file with text, and checks that the next request is a miss
|
||||
// answered with the variant made again, and the one after a hit.
|
||||
func TestProxy_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
|
||||
want, _ := getImage(t, svc, req)
|
||||
|
||||
path := variantFilePath(svc, imagerequest.CacheKey(req))
|
||||
|
||||
err := os.WriteFile(path, []byte("this is text, not an image"),
|
||||
imgcache.StorageFilePerm)
|
||||
if err != nil {
|
||||
t.Fatalf("replacing the variant file: %v", err)
|
||||
}
|
||||
|
||||
got, status := getImage(t, svc, req)
|
||||
t.Logf("after the replacement: %d bytes, %s", len(got), status)
|
||||
|
||||
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||
t.Errorf("after the replacement: Get() = %d bytes, %s; want the %d bytes "+
|
||||
"made again, %s", len(got), status, len(want), CacheMiss)
|
||||
}
|
||||
|
||||
got, status = getImage(t, svc, req)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_HitWaitsForNoDatabaseWrite holds the database's one
|
||||
// connection while a cached variant is requested. The hit must be answered
|
||||
// while the connection is still held, and its access time must reach the
|
||||
// database once it is free.
|
||||
func TestProxy_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
key := imagerequest.CacheKey(req)
|
||||
|
||||
getImage(t, svc, req)
|
||||
|
||||
// Each connection to an in-memory SQLite database opens a new, empty one,
|
||||
// so the goroutine that writes the access times must share the
|
||||
// connection that holds the schema.
|
||||
svc.db.SetMaxOpenConns(1)
|
||||
svc.cache.StartPendingAccessTimeWrites()
|
||||
|
||||
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
|
||||
|
||||
anHourAgo := time.Now().Add(-time.Hour)
|
||||
setVariantLastAccessed(t, svc.db, key, anHourAgo)
|
||||
|
||||
releaseDatabase := holdDatabase(t, svc.db)
|
||||
defer releaseDatabase()
|
||||
|
||||
results := startGet(t.Context(), svc, req)
|
||||
|
||||
select {
|
||||
case got := <-results:
|
||||
if got.err != nil {
|
||||
t.Fatalf("Get() of the cached variant error = %v", got.err)
|
||||
}
|
||||
case <-time.After(pendingAccessTimesWait):
|
||||
t.Fatal("the cache hit waited for the database")
|
||||
}
|
||||
|
||||
releaseDatabase()
|
||||
|
||||
deadline := time.Now().Add(pendingAccessTimesWait)
|
||||
|
||||
for countRows(t, svc.db, `
|
||||
SELECT COUNT(*) FROM variant_content
|
||||
WHERE cache_key = ? AND last_accessed_at > ?
|
||||
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the hit's access time was not written")
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
"sneak.berlin/go/pixa/internal/magic"
|
||||
)
|
||||
|
||||
// arrivalWait is how long a test gives requests it has started to reach the
|
||||
// point where they wait for a held fetch.
|
||||
const arrivalWait = 100 * time.Millisecond
|
||||
|
||||
// heldFetcher counts the fetches it is asked for and holds each one until
|
||||
// releaseFetches is called, so that a test can have requests arrive while a
|
||||
// fetch is in progress. started receives once for every fetch.
|
||||
type heldFetcher struct {
|
||||
upstream httpfetcher.Fetcher
|
||||
fetches atomic.Int32
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
releaseOnce sync.Once
|
||||
}
|
||||
|
||||
func (f *heldFetcher) Fetch(
|
||||
ctx context.Context, source imagerequest.Source,
|
||||
) (*httpfetcher.FetchResult, error) {
|
||||
f.fetches.Add(1)
|
||||
|
||||
f.started <- struct{}{}
|
||||
|
||||
select {
|
||||
case <-f.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
return f.upstream.Fetch(ctx, source)
|
||||
}
|
||||
|
||||
// releaseFetches lets every held fetch, and every later one, go on.
|
||||
func (f *heldFetcher) releaseFetches() {
|
||||
f.releaseOnce.Do(func() { close(f.release) })
|
||||
}
|
||||
|
||||
// setupHeldFetchProxy returns a test proxy whose fetches go through a
|
||||
// heldFetcher. Its database is limited to one connection: each connection to
|
||||
// an in-memory SQLite database opens a new, empty one, so requests running at
|
||||
// once must share the connection that holds the schema.
|
||||
func setupHeldFetchProxy(t *testing.T) (*testProxy, *TestFixtures, *heldFetcher) {
|
||||
t.Helper()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
svc.db.SetMaxOpenConns(1)
|
||||
|
||||
fetcher := &heldFetcher{
|
||||
upstream: svc.fetcher,
|
||||
started: make(chan struct{}, 100),
|
||||
release: make(chan struct{}),
|
||||
}
|
||||
svc.fetcher = fetcher
|
||||
|
||||
t.Cleanup(fetcher.releaseFetches)
|
||||
|
||||
return svc, fixtures, fetcher
|
||||
}
|
||||
|
||||
// photoVariant asks for the test photo, 100x100, at 50x25 as a JPEG of the
|
||||
// given quality and fit mode. Each call returns a new request, which a test
|
||||
// may change.
|
||||
func photoVariant(
|
||||
fixtures *TestFixtures, quality int, fit imagerequest.Fit,
|
||||
) *imagerequest.Request {
|
||||
return &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 25},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: quality,
|
||||
FitMode: fit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getResult is what one Get call returned, with the image read out.
|
||||
type getResult struct {
|
||||
image []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// startGet calls Get in a goroutine of its own and delivers what it returned
|
||||
// on the channel.
|
||||
func startGet(
|
||||
ctx context.Context, svc *testProxy, req *imagerequest.Request,
|
||||
) <-chan getResult {
|
||||
results := make(chan getResult, 1)
|
||||
|
||||
go func() {
|
||||
resp, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
results <- getResult{err: err}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
image, err := io.ReadAll(resp.Content)
|
||||
results <- getResult{image: image, err: err}
|
||||
}()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// jpegSize returns the width and height of the JPEG image in data, or 0 and 0
|
||||
// if data is not one.
|
||||
func jpegSize(data []byte) (int, int) {
|
||||
config, err := jpeg.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
return config.Width, config.Height
|
||||
}
|
||||
|
||||
// TestProxy_Get_ConcurrentMissesShareOneFetch starts several requests for
|
||||
// one uncached variant while the first one's fetch is held. Between them they
|
||||
// must fetch the source once and transcode it once, every one must be answered
|
||||
// with the same 50x25 JPEG, and each must count one miss.
|
||||
func TestProxy_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
|
||||
const requests = 8
|
||||
|
||||
pending := make([]<-chan getResult, 0, requests)
|
||||
for range requests {
|
||||
pending = append(pending,
|
||||
startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover)))
|
||||
}
|
||||
|
||||
<-fetcher.started
|
||||
time.Sleep(arrivalWait)
|
||||
fetcher.releaseFetches()
|
||||
|
||||
var first []byte
|
||||
|
||||
for i, results := range pending {
|
||||
got := <-results
|
||||
if got.err != nil {
|
||||
t.Fatalf("request %d: Get() error = %v", i, got.err)
|
||||
}
|
||||
|
||||
if width, height := jpegSize(got.image); width != 50 || height != 25 {
|
||||
t.Errorf("request %d: image is %dx%d, want a 50x25 JPEG", i, width, height)
|
||||
}
|
||||
|
||||
if first == nil {
|
||||
first = got.image
|
||||
} else if !bytes.Equal(got.image, first) {
|
||||
t.Errorf("request %d: image differs from request 0's", i)
|
||||
}
|
||||
}
|
||||
|
||||
if fetches := fetcher.fetches.Load(); fetches != 1 {
|
||||
t.Errorf("%d requests made %d upstream fetches, want 1", requests, fetches)
|
||||
}
|
||||
|
||||
// NewTestFS builds the same files the test proxy's fetcher serves.
|
||||
testFS, _ := NewTestFS(t)
|
||||
|
||||
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := counterValues{0, requests, 1, int64(len(photo)), 1}
|
||||
|
||||
if got := readCounters(t, svc); got != want {
|
||||
t.Errorf("counters = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_ConcurrentVariantsStayApart requests three variants of the
|
||||
// test photo at once that differ only in quality or fit. Each must be made by
|
||||
// a fetch and a transcode of its own, and each answer must be its own variant.
|
||||
func TestProxy_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
|
||||
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
lowQuality := startGet(t.Context(), svc,
|
||||
photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||
contain := startGet(t.Context(), svc,
|
||||
photoVariant(fixtures, 85, imagerequest.FitContain))
|
||||
|
||||
for range 3 {
|
||||
select {
|
||||
case <-fetcher.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("fewer fetches started than variants requested: " +
|
||||
"variants differing in quality or fit were merged")
|
||||
}
|
||||
}
|
||||
|
||||
fetcher.releaseFetches()
|
||||
|
||||
images := make(map[string][]byte)
|
||||
|
||||
for _, variant := range []struct {
|
||||
name string
|
||||
results <-chan getResult
|
||||
width, height int
|
||||
}{
|
||||
{"q=85 fit=cover", cover, 50, 25},
|
||||
{"q=40 fit=cover", lowQuality, 50, 25},
|
||||
{"q=85 fit=contain", contain, 25, 25},
|
||||
} {
|
||||
got := <-variant.results
|
||||
if got.err != nil {
|
||||
t.Fatalf("%s: Get() error = %v", variant.name, got.err)
|
||||
}
|
||||
|
||||
width, height := jpegSize(got.image)
|
||||
if width != variant.width || height != variant.height {
|
||||
t.Errorf("%s: image is %dx%d, want a %dx%d JPEG", variant.name,
|
||||
width, height, variant.width, variant.height)
|
||||
}
|
||||
|
||||
images[variant.name] = got.image
|
||||
}
|
||||
|
||||
if bytes.Equal(images["q=85 fit=cover"], images["q=40 fit=cover"]) {
|
||||
t.Error("q=40 was answered with the q=85 image")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_WaiterStopsWhenItsContextEnds has a second request for a
|
||||
// variant join the first one's held fetch, then ends the second request's
|
||||
// context. The second request must return at once with the context's error,
|
||||
// while the fetch is still held, and the first must still be answered.
|
||||
func TestProxy_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
|
||||
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
<-fetcher.started
|
||||
|
||||
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
||||
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
time.Sleep(arrivalWait)
|
||||
cancelWaiter()
|
||||
|
||||
select {
|
||||
case got := <-waiter:
|
||||
if !errors.Is(got.err, context.Canceled) {
|
||||
t.Errorf("waiting request: Get() error = %v, want %v",
|
||||
got.err, context.Canceled)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("waiting request did not return when its context ended")
|
||||
}
|
||||
|
||||
fetcher.releaseFetches()
|
||||
|
||||
got := <-first
|
||||
if got.err != nil {
|
||||
t.Fatalf("first request: Get() error = %v", got.err)
|
||||
}
|
||||
|
||||
if width, height := jpegSize(got.image); width != 50 || height != 25 {
|
||||
t.Errorf("first request: image is %dx%d, want a 50x25 JPEG", width, height)
|
||||
}
|
||||
|
||||
if fetches := fetcher.fetches.Load(); fetches != 1 {
|
||||
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_FirstRequestLeavingKeepsTheWork ends the context of the
|
||||
// request whose fetch is held, after a second request has joined it. The fetch
|
||||
// and transcode must go on and answer the second request. The first request
|
||||
// waits for its own work, as every request did before misses were shared, and
|
||||
// is answered too.
|
||||
func TestProxy_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
|
||||
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
<-fetcher.started
|
||||
|
||||
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
time.Sleep(arrivalWait)
|
||||
cancelFirst()
|
||||
fetcher.releaseFetches()
|
||||
|
||||
for name, results := range map[string]<-chan getResult{
|
||||
"first request": first, "second request": second,
|
||||
} {
|
||||
got := <-results
|
||||
if got.err != nil {
|
||||
t.Fatalf("%s: Get() error = %v", name, got.err)
|
||||
}
|
||||
|
||||
if width, height := jpegSize(got.image); width != 50 || height != 25 {
|
||||
t.Errorf("%s: image is %dx%d, want a 50x25 JPEG", name, width, height)
|
||||
}
|
||||
}
|
||||
|
||||
if fetches := fetcher.fetches.Load(); fetches != 1 {
|
||||
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_ConcurrentMissesShareAFailure has several requests for an
|
||||
// image that cannot be served arrive while its fetch is held: the one fetch
|
||||
// answers all of them with its error. The request after them is answered from
|
||||
// the negative cache, where the failure is kept.
|
||||
func TestProxy_Get_ConcurrentMissesShareAFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantErr error // what every request at once gets
|
||||
wantNextErr error // what the request after them gets
|
||||
wantFetches int32 // fetches once the request after them is answered
|
||||
}{
|
||||
{"upstream answers 404, kept in the negative cache",
|
||||
"/images/missing.jpg", httpfetcher.ErrUpstreamError,
|
||||
ErrNegativeCached, 1},
|
||||
{"source fails the magic byte check, kept in the negative cache",
|
||||
"/images/text.png", magic.ErrUnknownFormat, ErrNegativeCached, 1},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
|
||||
request := func() *imagerequest.Request {
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
req.Source.Path = tc.path
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
pending := make([]<-chan getResult, 0, 4)
|
||||
for range 4 {
|
||||
pending = append(pending, startGet(t.Context(), svc, request()))
|
||||
}
|
||||
|
||||
<-fetcher.started
|
||||
time.Sleep(arrivalWait)
|
||||
fetcher.releaseFetches()
|
||||
|
||||
for i, results := range pending {
|
||||
if got := <-results; !errors.Is(got.err, tc.wantErr) {
|
||||
t.Errorf("request %d: Get() error = %v, want %v", i, got.err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := svc.Get(t.Context(), request())
|
||||
if !errors.Is(err, tc.wantNextErr) {
|
||||
t.Errorf("next request: Get() error = %v, want %v", err, tc.wantNextErr)
|
||||
}
|
||||
|
||||
if fetches := fetcher.fetches.Load(); fetches != tc.wantFetches {
|
||||
t.Errorf("upstream fetches = %d, want %d", fetches, tc.wantFetches)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_EndedRequestFetchesNothing checks that a request whose
|
||||
// context has already ended when it misses the cache starts no fetch.
|
||||
func TestProxy_Get_EndedRequestFetchesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
_, err := svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Get() error = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
|
||||
if fetches := fetcher.fetches.Load(); fetches != 0 {
|
||||
t.Errorf("upstream fetches = %d, want 0", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_ReturnsByItsDeadline gives a request a deadline and holds
|
||||
// its fetch until the fetch's context ends, as the fetcher does while it waits
|
||||
// for a free connection to the host. The request must return by its deadline
|
||||
// with the deadline's error.
|
||||
func TestProxy_Get_ReturnsByItsDeadline(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, _ := setupHeldFetchProxy(t)
|
||||
|
||||
const timeout = 200 * time.Millisecond
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
select {
|
||||
case got := <-results:
|
||||
if !errors.Is(got.err, context.DeadlineExceeded) {
|
||||
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
||||
}
|
||||
case <-time.After(timeout + time.Second):
|
||||
t.Fatal("request did not return by its deadline")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_WaiterGetsProcessingTimedOut has a request with no deadline
|
||||
// join the held fetch of a first request with a short one. With the only
|
||||
// processing slot held, the first request's transcode waits for it until the
|
||||
// first request's deadline ends, and stops then. The waiting request, whose
|
||||
// own context has not ended, must get ErrProcessingTimedOut, which the image
|
||||
// routes answer 503 as when pixa is busy.
|
||||
func TestProxy_Get_WaiterGetsProcessingTimedOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
svc.processor = imageprocessor.New(
|
||||
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
|
||||
)
|
||||
|
||||
holdProcessingSlot(t, svc.processor)
|
||||
|
||||
const firstTimeout = 500 * time.Millisecond
|
||||
|
||||
firstCtx, cancelFirst := context.WithTimeout(t.Context(), firstTimeout)
|
||||
defer cancelFirst()
|
||||
|
||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
<-fetcher.started
|
||||
|
||||
waiter := startGet(t.Context(), svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
time.Sleep(arrivalWait)
|
||||
fetcher.releaseFetches()
|
||||
|
||||
t.Logf("first request: Get() error = %v", (<-first).err)
|
||||
|
||||
got := <-waiter
|
||||
t.Logf("waiting request: Get() error = %v", got.err)
|
||||
|
||||
if !errors.Is(got.err, ErrProcessingTimedOut) {
|
||||
t.Errorf("waiting request: Get() error = %v, want %v",
|
||||
got.err, ErrProcessingTimedOut)
|
||||
}
|
||||
}
|
||||
|
||||
// panickingFetcher panics on every fetch.
|
||||
type panickingFetcher struct{}
|
||||
|
||||
func (panickingFetcher) Fetch(
|
||||
context.Context, imagerequest.Source,
|
||||
) (*httpfetcher.FetchResult, error) {
|
||||
panic("upstream fetcher panicked")
|
||||
}
|
||||
|
||||
// TestProxy_Get_PanicBecomesAnError checks that a panic while a variant is
|
||||
// being made reaches its request as an error naming the panic, instead of
|
||||
// being raised again.
|
||||
func TestProxy_Get_PanicBecomesAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
svc.fetcher = panickingFetcher{}
|
||||
|
||||
var err error
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("Get() panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
}()
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
||||
t.Errorf("Get() error = %v, want one naming the panic", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_PanicIsReportedToSentry checks that a panic while a variant
|
||||
// is being made is reported through the Sentry hub on the request's context,
|
||||
// where the Sentry middleware puts one when sentry_dsn is set.
|
||||
func TestProxy_Get_PanicIsReportedToSentry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
svc.fetcher = panickingFetcher{}
|
||||
|
||||
transport := &sentry.MockTransport{}
|
||||
|
||||
client, err := sentry.NewClient(sentry.ClientOptions{
|
||||
Dsn: "https://abc123@sentry.example.com/42",
|
||||
Transport: transport,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := sentry.SetHubOnContext(t.Context(),
|
||||
sentry.NewHub(client, sentry.NewScope()))
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Fatalf("Get() panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
_, _ = svc.Get(ctx, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
}()
|
||||
|
||||
events := transport.Events()
|
||||
if len(events) != 1 || events[0].Message != "upstream fetcher panicked" {
|
||||
t.Errorf("Sentry events = %+v, want one for the panic", events)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// TestNew_RegistersItsCountersOnItsRegistry checks that New registers the
|
||||
// proxy's counters on the registry it is given, under the names README.md
|
||||
// gives them. The cache registers its own counters on another registry.
|
||||
func TestNew_RegistersItsCountersOnItsRegistry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, err := imgcache.NewCache(setupTestDB(t),
|
||||
imgcache.Config{StateDir: t.TempDir(), MaxBytes: 1 << 30},
|
||||
prometheus.NewRegistry())
|
||||
if err != nil {
|
||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||
}
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
_, err = New(&Config{Cache: cache}, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
names := []string{
|
||||
"pixa_upstream_fetches_total",
|
||||
"pixa_upstream_fetch_bytes_total",
|
||||
"pixa_transforms_total",
|
||||
}
|
||||
|
||||
count, err := testutil.GatherAndCount(registry, names...)
|
||||
if err != nil {
|
||||
t.Fatalf("testutil.GatherAndCount() error = %v", err)
|
||||
}
|
||||
|
||||
if count != len(names) {
|
||||
t.Errorf("%d of the counters %v are on the registry, want %d",
|
||||
count, names, len(names))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew_FailsWhenItsCountersCannotBeRegistered makes a second proxy on
|
||||
// the registry of a first. Its counters have the same names, so the
|
||||
// registry refuses them, and New must return that error.
|
||||
func TestNew_FailsWhenItsCountersCannotBeRegistered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, err := imgcache.NewCache(setupTestDB(t),
|
||||
imgcache.Config{StateDir: t.TempDir(), MaxBytes: 1 << 30},
|
||||
prometheus.NewRegistry())
|
||||
if err != nil {
|
||||
t.Fatalf("imgcache.NewCache() error = %v", err)
|
||||
}
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
_, err = New(&Config{Cache: cache}, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("New() of the first proxy error = %v", err)
|
||||
}
|
||||
|
||||
_, err = New(&Config{Cache: cache}, registry)
|
||||
t.Logf("New() of the second proxy error = %v", err)
|
||||
|
||||
var alreadyRegistered prometheus.AlreadyRegisteredError
|
||||
if !errors.As(err, &alreadyRegistered) {
|
||||
t.Errorf("New() of the second proxy error = %v, want %T",
|
||||
err, alreadyRegistered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSizePercentSafeWithZeroFetchBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Simulate the calculation from processAndStore
|
||||
fetchBytes := int64(0)
|
||||
outputSize := int64(100)
|
||||
|
||||
var sizePercent float64
|
||||
if fetchBytes > 0 {
|
||||
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
|
||||
}
|
||||
|
||||
// sizePercent should be 0, not Inf or NaN
|
||||
if math.IsInf(sizePercent, 0) || math.IsNaN(sizePercent) {
|
||||
t.Errorf("sizePercent = %f, should not be Inf/NaN", sizePercent)
|
||||
}
|
||||
|
||||
// Should produce valid log output
|
||||
result := fmt.Sprintf("%.1f%%", sizePercent)
|
||||
if result != "0.0%" {
|
||||
t.Errorf("formatted size ratio = %q, want %q", result, "0.0%")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizePercentNormalCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fetchBytes := int64(1000)
|
||||
outputSize := int64(500)
|
||||
|
||||
var sizePercent float64
|
||||
if fetchBytes > 0 {
|
||||
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
|
||||
}
|
||||
|
||||
if math.Abs(sizePercent-50.0) > 0.001 {
|
||||
t.Errorf("sizePercent = %f, want 50.0", sizePercent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
// Package imageproxy fetches, transforms, caches and serves images. Its
|
||||
// Proxy serves an image from the cache when it can, and otherwise makes it
|
||||
// from the cached or fetched source image.
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"golang.org/x/sync/singleflight"
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
"sneak.berlin/go/pixa/internal/magic"
|
||||
)
|
||||
|
||||
// Proxy orchestrates the cache, fetcher, and processor.
|
||||
type Proxy struct {
|
||||
cache *imgcache.Cache
|
||||
fetcher httpfetcher.Fetcher
|
||||
processor *imageprocessor.ImageProcessor
|
||||
log *slog.Logger
|
||||
maxResponseSize int64
|
||||
// variantsInProgress lets the requests that miss the same variant at the
|
||||
// same time share one fetch and one transcode.
|
||||
variantsInProgress singleflight.Group
|
||||
|
||||
// The counts of upstream fetches and transforms, made and registered by
|
||||
// New. They are kept in memory, so adding to one waits for nothing.
|
||||
upstreamFetches prometheus.Counter
|
||||
upstreamFetchBytes prometheus.Counter
|
||||
transforms prometheus.Counter
|
||||
}
|
||||
|
||||
// Config holds the proxy's configuration.
|
||||
type Config struct {
|
||||
// Cache is the cache instance
|
||||
Cache *imgcache.Cache
|
||||
// Fetcher fetches source images from upstream hosts
|
||||
Fetcher httpfetcher.Fetcher
|
||||
// Processor converts and resizes the images
|
||||
Processor *imageprocessor.ImageProcessor
|
||||
// MaxResponseSize is the largest source accepted, in bytes; a cached
|
||||
// source larger than this is fetched again
|
||||
MaxResponseSize int64
|
||||
// Logger for logging
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// errCacheRequired is returned when the proxy is built without a cache.
|
||||
var errCacheRequired = errors.New("cache is required")
|
||||
|
||||
// New creates a new image proxy and registers its counters on
|
||||
// metricsRegistry. pixad passes Prometheus' default registry, the one
|
||||
// /metrics serves. It fails when metricsRegistry already holds counters of
|
||||
// the same names.
|
||||
func New(
|
||||
cfg *Config, metricsRegistry prometheus.Registerer,
|
||||
) (*Proxy, error) {
|
||||
if cfg.Cache == nil {
|
||||
return nil, errCacheRequired
|
||||
}
|
||||
|
||||
log := cfg.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
// JPEG XL is the default output format, so pixad does not start
|
||||
// without it.
|
||||
err := imageprocessor.CheckJPEGXLSupport()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &Proxy{
|
||||
cache: cfg.Cache,
|
||||
fetcher: cfg.Fetcher,
|
||||
processor: cfg.Processor,
|
||||
log: log,
|
||||
maxResponseSize: cfg.MaxResponseSize,
|
||||
upstreamFetches: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_upstream_fetches_total",
|
||||
Help: "Fetches of a source image from upstream that read any bytes.",
|
||||
}),
|
||||
upstreamFetchBytes: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_upstream_fetch_bytes_total",
|
||||
Help: "Bytes read from upstream by fetches of source images.",
|
||||
}),
|
||||
transforms: prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_transforms_total",
|
||||
Help: "Images the image processor transformed.",
|
||||
}),
|
||||
}
|
||||
|
||||
for _, counter := range []prometheus.Counter{
|
||||
p.upstreamFetches, p.upstreamFetchBytes, p.transforms,
|
||||
} {
|
||||
err = metricsRegistry.Register(counter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register the proxy's counters: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ErrNegativeCached is returned when a URL is in the negative cache (recently failed).
|
||||
var ErrNegativeCached = errors.New("request is in negative cache (recently failed)")
|
||||
|
||||
// ErrProcessingTimedOut is returned when processing an image stopped because
|
||||
// the downstream_timeout of the request doing it ended, to that request and to
|
||||
// every request that waited for its image.
|
||||
var ErrProcessingTimedOut = errors.New(
|
||||
"image processing stopped at the deadline of the request doing it")
|
||||
|
||||
// ImageResponse represents a processed image ready to serve
|
||||
type ImageResponse struct {
|
||||
// Content is the image data reader
|
||||
Content io.ReadCloser
|
||||
// ContentLength is the size in bytes (-1 if unknown)
|
||||
ContentLength int64
|
||||
// ContentType is the MIME type of the response
|
||||
ContentType string
|
||||
// ETag is the entity tag for caching
|
||||
ETag string
|
||||
// CacheStatus indicates HIT or MISS
|
||||
CacheStatus CacheStatus
|
||||
// FetchedBytes is the number of bytes fetched from upstream (0 if cache hit)
|
||||
FetchedBytes int64
|
||||
}
|
||||
|
||||
// CacheStatus indicates whether the response was served from cache.
|
||||
type CacheStatus string
|
||||
|
||||
// Cache status values for response headers.
|
||||
const (
|
||||
CacheHit CacheStatus = "HIT"
|
||||
CacheMiss CacheStatus = "MISS"
|
||||
)
|
||||
|
||||
// Get retrieves a processed image, fetching and processing if necessary.
|
||||
// While its source URL is in the negative cache, a cached variant is still
|
||||
// served and a variant not in the cache is still made from a cached source;
|
||||
// only a request that needs the source fetched is refused.
|
||||
//
|
||||
// A cache hit opens the variant file and makes no database call: the cache
|
||||
// hands its access time to the goroutine that writes access times, without
|
||||
// waiting for it, and its hit is counted in memory. A variant file that
|
||||
// cannot be served, such as one whose first bytes are not those of an output
|
||||
// format, is a miss, and the variant is made again.
|
||||
func (p *Proxy) Get(
|
||||
ctx context.Context, req *imagerequest.Request,
|
||||
) (*ImageResponse, error) {
|
||||
cacheKey := imagerequest.CacheKey(req)
|
||||
|
||||
reader, size, contentType, err := p.cache.Variant(cacheKey)
|
||||
if err == nil {
|
||||
p.cache.IncrementStats(true)
|
||||
|
||||
return &ImageResponse{
|
||||
Content: reader,
|
||||
ContentLength: size,
|
||||
ContentType: contentType,
|
||||
CacheStatus: CacheHit,
|
||||
ETag: formatETag(cacheKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, imgcache.ErrNotFound) {
|
||||
p.log.Warn("cached variant not served, making it again",
|
||||
"cache_key", cacheKey, "error", err)
|
||||
}
|
||||
|
||||
// Cache miss - get the variant, processed once for all the requests that
|
||||
// miss it at the same time, then count this request's miss, also when it
|
||||
// failed or the request context has ended meanwhile
|
||||
response, err := p.processOrWait(ctx, req)
|
||||
|
||||
p.cache.IncrementStats(false)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response.CacheStatus = CacheMiss
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||
// ends, and returns how many images were still being processed then.
|
||||
func (p *Proxy) WaitForProcessing(ctx context.Context) int {
|
||||
return p.processor.WaitForProcessing(ctx)
|
||||
}
|
||||
|
||||
// errPanicked is returned when processing a variant panicked.
|
||||
var errPanicked = errors.New("panic while processing image")
|
||||
|
||||
// processOrWait returns the variant req asks for. The first of the requests
|
||||
// that miss a variant at the same time processes it, and singleflight hands
|
||||
// its result, or its error, to the others: they fetch nothing, read no source
|
||||
// and take no upstream connection or processing slot. The processing ignores
|
||||
// the first request's cancellation, so the others are still served if that
|
||||
// client goes away, but keeps its deadline.
|
||||
func (p *Proxy) processOrWait(
|
||||
ctx context.Context, req *imagerequest.Request,
|
||||
) (*ImageResponse, error) {
|
||||
// A request that has already ended starts no processing
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
cacheKey := imagerequest.CacheKey(req)
|
||||
|
||||
// Closed when this request's own function runs, which singleflight does
|
||||
// only when no other request is processing the variant
|
||||
processing := make(chan struct{})
|
||||
|
||||
results := p.variantsInProgress.DoChan(string(cacheKey),
|
||||
func() (_ any, err error) {
|
||||
close(processing)
|
||||
|
||||
// singleflight would raise a panic again in a goroutine of its
|
||||
// own, where no handler recovers it, and stop pixad. It is
|
||||
// reported through the Sentry hub that the Sentry middleware puts
|
||||
// on the request's context when sentry_dsn is set.
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered != nil {
|
||||
p.log.Error("panic while processing image",
|
||||
"host", req.Source.Host, "path", req.Source.Path,
|
||||
"panic", recovered, "stack", string(debug.Stack()))
|
||||
|
||||
if hub := sentry.GetHubFromContext(ctx); hub != nil {
|
||||
hub.RecoverWithContext(ctx, recovered)
|
||||
}
|
||||
|
||||
err = fmt.Errorf("%w: %v", errPanicked, recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
processingCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||
defer cancel()
|
||||
|
||||
return p.processFromSourceOrFetch(processingCtx, req, cacheKey)
|
||||
})
|
||||
|
||||
var result singleflight.Result
|
||||
|
||||
select {
|
||||
case result = <-results:
|
||||
case <-ctx.Done():
|
||||
select {
|
||||
case <-processing:
|
||||
// This request is processing the variant: it waits for the
|
||||
// result, as every request did before misses were shared
|
||||
result = <-results
|
||||
default:
|
||||
// Another request is processing the variant, or this request's
|
||||
// function has not started yet; the processing goes on without it
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if result.Err != nil {
|
||||
return nil, result.Err
|
||||
}
|
||||
|
||||
variant, _ := result.Val.(*processedVariant)
|
||||
|
||||
return &ImageResponse{
|
||||
Content: io.NopCloser(bytes.NewReader(variant.data)),
|
||||
ContentLength: int64(len(variant.data)),
|
||||
ContentType: variant.contentType,
|
||||
FetchedBytes: variant.fetchedBytes,
|
||||
ETag: formatETag(cacheKey),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// withoutCancelKeepingDeadline returns ctx without its cancellation but with
|
||||
// its deadline, if it has one, and the func that releases the returned
|
||||
// context.
|
||||
func withoutCancelKeepingDeadline(
|
||||
ctx context.Context,
|
||||
) (context.Context, context.CancelFunc) {
|
||||
deadline, hasDeadline := ctx.Deadline()
|
||||
if !hasDeadline {
|
||||
return context.WithoutCancel(ctx), func() {}
|
||||
}
|
||||
|
||||
return context.WithDeadline(context.WithoutCancel(ctx), deadline)
|
||||
}
|
||||
|
||||
// loadCachedSource opens src's cached source, without reading it, and
|
||||
// returns it with its size; nil if it is not in the cache, cannot be read,
|
||||
// is empty or exceeds maxResponseSize.
|
||||
func (p *Proxy) loadCachedSource(
|
||||
ctx context.Context, src imagerequest.Source,
|
||||
) (io.ReadCloser, int64) {
|
||||
reader, size, err := p.cache.Source(ctx, src)
|
||||
if errors.Is(err, imgcache.ErrNotFound) {
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
p.log.Warn("failed to load cached source, fetching", "error", err)
|
||||
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
p.log.Debug("using cached source", "source_key", src.Key())
|
||||
|
||||
if size > p.maxResponseSize {
|
||||
_ = reader.Close()
|
||||
|
||||
p.log.Warn("cached source exceeds max response size, discarding",
|
||||
"source_key", src.Key(),
|
||||
"max_bytes", p.maxResponseSize,
|
||||
)
|
||||
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
if size == 0 {
|
||||
_ = reader.Close()
|
||||
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
return reader, size
|
||||
}
|
||||
|
||||
// processFromSourceOrFetch processes an image, using cached source content
|
||||
// if available.
|
||||
func (p *Proxy) processFromSourceOrFetch(
|
||||
ctx context.Context,
|
||||
req *imagerequest.Request,
|
||||
cacheKey imagerequest.VariantKey,
|
||||
) (*processedVariant, error) {
|
||||
source, sourceSize := p.loadCachedSource(ctx, req.Source)
|
||||
|
||||
// Fetch from upstream if we don't have source data or it's empty
|
||||
if source == nil {
|
||||
return p.fetchAndProcess(ctx, req, cacheKey)
|
||||
}
|
||||
|
||||
defer func() { _ = source.Close() }()
|
||||
|
||||
// Process using cached source; nothing was fetched from upstream. The
|
||||
// image processor reads the source only once it has a processing slot,
|
||||
// so a request waiting for one holds none of it in memory.
|
||||
return p.processAndStore(ctx, req, cacheKey, source, sourceSize)
|
||||
}
|
||||
|
||||
// fetchAndProcess fetches from upstream, processes, and caches the result.
|
||||
// It counts the fetch with the bytes read from upstream, including when
|
||||
// reading the response or a later step fails. It fetches nothing while the
|
||||
// source URL is in the negative cache.
|
||||
func (p *Proxy) fetchAndProcess(
|
||||
ctx context.Context,
|
||||
req *imagerequest.Request,
|
||||
cacheKey imagerequest.VariantKey,
|
||||
) (*processedVariant, error) {
|
||||
// Check negative cache - skip fetching for recently-failed URLs
|
||||
negHit, err := p.cache.Failure(ctx, req.Source)
|
||||
if err != nil {
|
||||
p.log.Warn("negative cache check failed", "error", err)
|
||||
}
|
||||
|
||||
if negHit {
|
||||
p.log.Debug("negative cache hit",
|
||||
"host", req.Source.Host,
|
||||
"path", req.Source.Path,
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("%w: %w", httpfetcher.ErrUpstreamError, ErrNegativeCached)
|
||||
}
|
||||
|
||||
// Fetch from upstream
|
||||
p.log.Debug("fetching from upstream", "host", req.Source.Host,
|
||||
"path", req.Source.Path, "query", req.Source.Query)
|
||||
|
||||
fetchResult, err := p.fetcher.Fetch(ctx, req.Source)
|
||||
if err != nil {
|
||||
// Store negative cache for certain errors
|
||||
if isNegativeCacheable(err) {
|
||||
p.storeNegative(ctx, req)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("upstream fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// Closing the body frees the upstream connection. It is closed only
|
||||
// after processing, so the fetcher's connection limit also bounds the
|
||||
// fetched sources held in memory while their requests wait for a
|
||||
// processing slot.
|
||||
defer func() { _ = fetchResult.Content.Close() }()
|
||||
|
||||
// Read and validate the source content
|
||||
sourceData, err := io.ReadAll(fetchResult.Content)
|
||||
fetchBytes := int64(len(sourceData))
|
||||
|
||||
// Counted also when the request context has ended meanwhile
|
||||
p.incrementUpstreamFetch(fetchBytes)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read upstream response: %w", err)
|
||||
}
|
||||
|
||||
// Calculate download bitrate
|
||||
var downloadRate string
|
||||
|
||||
if fetchResult.FetchDurationMs > 0 {
|
||||
seconds := float64(fetchResult.FetchDurationMs) / 1000.0 //nolint:mnd // ms to seconds
|
||||
bitsPerSecond := float64(fetchBytes*8) / seconds //nolint:mnd // bytes to bits
|
||||
downloadRate = humanize.SI(bitsPerSecond, "bps")
|
||||
}
|
||||
|
||||
// Log upstream fetch details
|
||||
p.log.Info("upstream fetched",
|
||||
"request_id", middleware.GetReqID(ctx),
|
||||
"host", req.Source.Host,
|
||||
"path", req.Source.Path,
|
||||
"bytes", fetchBytes,
|
||||
"fetch_ms", fetchResult.FetchDurationMs,
|
||||
"rate", downloadRate,
|
||||
"remote_addr", fetchResult.RemoteAddr,
|
||||
"http", fetchResult.HTTPVersion,
|
||||
"tls", fetchResult.TLSVersion,
|
||||
"cipher", fetchResult.TLSCipherSuite,
|
||||
)
|
||||
|
||||
// Validate magic bytes match content type. A body that does not is the
|
||||
// upstream's error, so it gets a negative cache entry, as an upstream
|
||||
// error status does.
|
||||
err = magic.ValidateMagicBytes(sourceData, fetchResult.ContentType)
|
||||
if err != nil {
|
||||
p.log.Warn("source refused", "request_id", middleware.GetReqID(ctx),
|
||||
"host", req.Source.Host, "path", req.Source.Path,
|
||||
"content_type", fetchResult.ContentType, "error", err)
|
||||
p.storeNegative(ctx, req)
|
||||
|
||||
return nil, fmt.Errorf("content validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Store source content
|
||||
_, err = p.cache.PutSource(ctx, req.Source, bytes.NewReader(sourceData))
|
||||
if err != nil {
|
||||
p.log.Warn("failed to store source content", "error", err)
|
||||
// Continue even if caching fails
|
||||
}
|
||||
|
||||
return p.processAndStore(
|
||||
ctx, req, cacheKey, bytes.NewReader(sourceData), fetchBytes,
|
||||
)
|
||||
}
|
||||
|
||||
// incrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||
// A fetch that read no bytes is not counted.
|
||||
func (p *Proxy) incrementUpstreamFetch(fetchBytes int64) {
|
||||
if fetchBytes <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
p.upstreamFetches.Inc()
|
||||
p.upstreamFetchBytes.Add(float64(fetchBytes))
|
||||
}
|
||||
|
||||
// storeNegative stores a negative cache entry for req's source, whose fetch
|
||||
// failed, so the source is not fetched again until the entry expires. A
|
||||
// failure to store the entry is only logged.
|
||||
func (p *Proxy) storeNegative(ctx context.Context, req *imagerequest.Request) {
|
||||
err := p.cache.PutFailure(ctx, req.Source)
|
||||
if err != nil {
|
||||
p.log.Warn("failed to store negative cache entry",
|
||||
"host", req.Source.Host, "path", req.Source.Path, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// processedVariant is a variant as processAndStore made it. Each request that
|
||||
// shared its processing serves it through a reader of its own.
|
||||
type processedVariant struct {
|
||||
data []byte
|
||||
contentType string
|
||||
fetchedBytes int64
|
||||
}
|
||||
|
||||
// processAndStore processes the image read from source and stores the
|
||||
// result.
|
||||
func (p *Proxy) processAndStore(
|
||||
ctx context.Context,
|
||||
req *imagerequest.Request,
|
||||
cacheKey imagerequest.VariantKey,
|
||||
source io.Reader,
|
||||
fetchBytes int64,
|
||||
) (*processedVariant, error) {
|
||||
// Process the image
|
||||
processStart := time.Now()
|
||||
|
||||
// ctx has the deadline of the request doing the processing, which can
|
||||
// end while a request waiting for its image has time left
|
||||
processResult, err := p.processor.Process(ctx, source, &req.Transform)
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("%w: %w", ErrProcessingTimedOut, err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image processing failed: %w", err)
|
||||
}
|
||||
|
||||
processDuration := time.Since(processStart)
|
||||
|
||||
// Counted also when the request context has ended meanwhile
|
||||
p.transforms.Inc()
|
||||
|
||||
// Read processed content
|
||||
processedData, err := io.ReadAll(processResult.Content)
|
||||
_ = processResult.Content.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read processed content: %w", err)
|
||||
}
|
||||
|
||||
// Log conversion details
|
||||
outputSize := int64(len(processedData))
|
||||
|
||||
var sizePercent float64
|
||||
if fetchBytes > 0 {
|
||||
//nolint:mnd // percentage calculation
|
||||
sizePercent = float64(outputSize) / float64(fetchBytes) * 100.0
|
||||
}
|
||||
|
||||
p.log.Info("image converted",
|
||||
"request_id", middleware.GetReqID(ctx),
|
||||
"host", req.Source.Host,
|
||||
"path", req.Source.Path,
|
||||
"src_format", processResult.InputFormat,
|
||||
"dst_format", req.Transform.Format,
|
||||
"src_bytes", fetchBytes,
|
||||
"dst_bytes", outputSize,
|
||||
"src_dimensions", fmt.Sprintf("%dx%d",
|
||||
processResult.InputWidth, processResult.InputHeight),
|
||||
"dst_dimensions", fmt.Sprintf("%dx%d",
|
||||
processResult.Width, processResult.Height),
|
||||
"size_ratio", fmt.Sprintf("%.1f%%", sizePercent),
|
||||
"convert_ms", processDuration.Milliseconds(),
|
||||
"quality", req.Transform.Quality,
|
||||
"fit", req.Transform.FitMode,
|
||||
)
|
||||
|
||||
// Store variant to cache
|
||||
err = p.cache.PutVariant(ctx, cacheKey, bytes.NewReader(processedData))
|
||||
if err != nil {
|
||||
p.log.Warn("failed to store variant", "error", err)
|
||||
// Continue even if caching fails
|
||||
}
|
||||
|
||||
return &processedVariant{
|
||||
data: processedData,
|
||||
contentType: processResult.ContentType,
|
||||
fetchedBytes: fetchBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isNegativeCacheable returns true if the error should be cached.
|
||||
func isNegativeCacheable(err error) bool {
|
||||
return errors.Is(err, httpfetcher.ErrUpstreamError)
|
||||
}
|
||||
|
||||
// etagHashLength is the number of hash characters to use for ETags.
|
||||
const etagHashLength = 16
|
||||
|
||||
// formatETag formats a VariantKey as a quoted ETag value.
|
||||
func formatETag(key imagerequest.VariantKey) string {
|
||||
hash := string(key)
|
||||
// Use first 16 characters of hash for a shorter but still unique ETag
|
||||
if len(hash) > etagHashLength {
|
||||
hash = hash[:etagHashLength]
|
||||
}
|
||||
|
||||
return `"` + hash + `"`
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
"sneak.berlin/go/pixa/internal/magic"
|
||||
)
|
||||
|
||||
// Test data literals used repeatedly in this file (goconst).
|
||||
const (
|
||||
testPathPhoto = "/images/photo.jpg"
|
||||
testPathUpload = "/uploads/image.jpg"
|
||||
)
|
||||
|
||||
func TestProxy_Get_GoodHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
// Verify we got content
|
||||
data, err := io.ReadAll(resp.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Error("expected non-empty response")
|
||||
}
|
||||
|
||||
if resp.ContentType != testContentTypeJPEG {
|
||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, testContentTypeJPEG)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_OtherHost checks that the proxy serves a second host as it
|
||||
// serves GoodHost.
|
||||
func TestProxy_Get_OtherHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.OtherHost,
|
||||
Path: testPathUpload,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
// Should fetch successfully
|
||||
resp, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
data, err := io.ReadAll(resp.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Error("expected non-empty response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_InvalidFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
req.Source.Path = "/images/fake.jpg"
|
||||
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
||||
|
||||
// Should fail because magic bytes don't match
|
||||
_, err := svc.Get(ctx, req)
|
||||
if err == nil {
|
||||
t.Error("Get() expected error for invalid file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_NotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
req.Source.Path = "/images/nonexistent.jpg"
|
||||
req.Transform.Size = imagerequest.Size{Width: 50, Height: 50}
|
||||
|
||||
_, err := svc.Get(ctx, req)
|
||||
if err == nil {
|
||||
t.Error("Get() expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_FormatConversion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sourcePath string
|
||||
outFormat imagerequest.Format
|
||||
wantMIME string
|
||||
}{
|
||||
{
|
||||
name: "JPEG to PNG",
|
||||
sourcePath: testPathPhoto,
|
||||
outFormat: imagerequest.FormatPNG,
|
||||
wantMIME: "image/png",
|
||||
},
|
||||
{
|
||||
name: "PNG to JPEG",
|
||||
sourcePath: "/images/logo.png",
|
||||
outFormat: imagerequest.FormatJPEG,
|
||||
wantMIME: testContentTypeJPEG,
|
||||
},
|
||||
{
|
||||
name: "GIF to PNG",
|
||||
sourcePath: "/images/animation.gif",
|
||||
outFormat: imagerequest.FormatPNG,
|
||||
wantMIME: "image/png",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{Host: fixtures.GoodHost, Path: tt.sourcePath},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: tt.outFormat, Quality: 85, FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
if resp.ContentType != tt.wantMIME {
|
||||
t.Errorf("ContentType = %q, want %q", resp.ContentType, tt.wantMIME)
|
||||
}
|
||||
|
||||
// Verify magic bytes match the expected format
|
||||
data, err := io.ReadAll(resp.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
detectedMIME, err := magic.DetectFormat(data)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to detect format: %v", err)
|
||||
}
|
||||
|
||||
expectedFormat, ok := magic.MIMEToImageFormat(tt.wantMIME)
|
||||
if !ok {
|
||||
t.Fatalf("unknown format for MIME type: %s", tt.wantMIME)
|
||||
}
|
||||
|
||||
detectedFormat, ok := magic.MIMEToImageFormat(string(detectedMIME))
|
||||
if !ok {
|
||||
t.Fatalf("unknown format for detected MIME type: %s", detectedMIME)
|
||||
}
|
||||
|
||||
if detectedFormat != expectedFormat {
|
||||
t.Errorf("detected format = %q, want %q", detectedFormat, expectedFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_Caching(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
// First request - should be a cache miss
|
||||
resp1, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
if resp1.CacheStatus != CacheMiss {
|
||||
t.Errorf("first request CacheStatus = %q, want %q", resp1.CacheStatus, CacheMiss)
|
||||
}
|
||||
|
||||
data1, err := io.ReadAll(resp1.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read first response: %v", err)
|
||||
}
|
||||
|
||||
_ = resp1.Content.Close()
|
||||
|
||||
// Second request - should be a cache hit
|
||||
resp2, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() second request error = %v", err)
|
||||
}
|
||||
|
||||
if resp2.CacheStatus != CacheHit {
|
||||
t.Errorf("second request CacheStatus = %q, want %q", resp2.CacheStatus, CacheHit)
|
||||
}
|
||||
|
||||
data2, err := io.ReadAll(resp2.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read second response: %v", err)
|
||||
}
|
||||
|
||||
_ = resp2.Content.Close()
|
||||
|
||||
// Content should be identical
|
||||
if len(data1) != len(data2) {
|
||||
t.Errorf("response sizes differ: %d vs %d", len(data1), len(data2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo caches a source and
|
||||
// a variant of it and sets back two days every time pixa stored for them.
|
||||
// For the source: last_accessed_at in source_content, and the access and
|
||||
// modification times of its file. For the variant: last_accessed_at in
|
||||
// variant_content, and the access and modification times of its file. It
|
||||
// then checks that the variant is still served from the cache and that a new
|
||||
// variant is made from the cached source without fetching it again.
|
||||
//
|
||||
// Left at the present: the files' status change and birth times, which a
|
||||
// program cannot set, and the times of the directories holding the files,
|
||||
// which other entries share.
|
||||
func TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
variant := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
|
||||
resp, err := svc.Get(t.Context(), variant)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
twoDaysAgo := time.Now().AddDate(0, 0, -2)
|
||||
backdateSource(t, svc, variant, twoDaysAgo)
|
||||
backdateVariant(t, svc, variant, twoDaysAgo)
|
||||
|
||||
resp, err = svc.Get(t.Context(), variant)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
if resp.CacheStatus != CacheHit {
|
||||
t.Errorf("cached variant CacheStatus = %q, want %q",
|
||||
resp.CacheStatus, CacheHit)
|
||||
}
|
||||
|
||||
// Another quality is a new variant, made from the cached source
|
||||
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||
if err != nil {
|
||||
t.Fatalf("Get() of a new variant error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
fetches := readCounters(t, svc).upstreamFetchCount
|
||||
if fetches != 1 {
|
||||
t.Errorf("upstream fetches = %d, want 1", fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// backdateSource sets every time pixa stored for req's cached source to when,
|
||||
// as listed on TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||
func backdateSource(
|
||||
t *testing.T, svc *testProxy, req *imagerequest.Request, when time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
key := req.Source.Key()
|
||||
setSourceLastAccessed(t, svc.db, key, when)
|
||||
|
||||
path := sourceFilePath(svc, key)
|
||||
|
||||
err := os.Chtimes(path, when, when)
|
||||
if err != nil {
|
||||
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// backdateVariant sets every time pixa stored for the cached variant req asks
|
||||
// for to when, as listed on
|
||||
// TestProxy_Get_ServesSourceAndVariantCachedTwoDaysAgo.
|
||||
func backdateVariant(
|
||||
t *testing.T, svc *testProxy, req *imagerequest.Request, when time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
key := imagerequest.CacheKey(req)
|
||||
path := variantFilePath(svc, key)
|
||||
|
||||
setVariantLastAccessed(t, svc.db, key, when)
|
||||
|
||||
err := os.Chtimes(path, when, when)
|
||||
if err != nil {
|
||||
t.Fatalf("setting back the times of %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_DifferentSizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Request same image at different sizes
|
||||
sizes := []imagerequest.Size{
|
||||
{Width: 25, Height: 25},
|
||||
{Width: 50, Height: 50},
|
||||
{Width: 75, Height: 75},
|
||||
}
|
||||
|
||||
responses := make([][]byte, 0, len(sizes))
|
||||
|
||||
for _, size := range sizes {
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: size,
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error for size %v = %v", size, err)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read response: %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
responses = append(responses, data)
|
||||
}
|
||||
|
||||
// All responses should be different sizes (different cache entries)
|
||||
for i := range len(responses) - 1 {
|
||||
if len(responses[i]) == len(responses[i+1]) {
|
||||
// Not necessarily an error, but worth noting
|
||||
t.Logf("responses %d and %d have same size: %d bytes",
|
||||
i, i+1, len(responses[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_ContextCancellation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := svc.Get(ctx, req)
|
||||
if err == nil {
|
||||
t.Error("Get() expected error for cancelled context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_ReturnsETag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Content.Close() }()
|
||||
|
||||
// ETag should be set
|
||||
if resp.ETag == "" {
|
||||
t.Error("ETag should be set in response")
|
||||
}
|
||||
|
||||
// ETag should be properly quoted
|
||||
if len(resp.ETag) < 2 || resp.ETag[0] != '"' || resp.ETag[len(resp.ETag)-1] != '"' {
|
||||
t.Errorf("ETag should be quoted, got %q", resp.ETag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_ETagConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
// First request
|
||||
resp1, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
etag1 := resp1.ETag
|
||||
|
||||
_ = resp1.Content.Close()
|
||||
|
||||
// Second request (from cache)
|
||||
resp2, err := svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() second request error = %v", err)
|
||||
}
|
||||
|
||||
etag2 := resp2.ETag
|
||||
|
||||
_ = resp2.Content.Close()
|
||||
|
||||
// ETags should be identical for the same content
|
||||
if etag1 != etag2 {
|
||||
t.Errorf("ETags should match: %q != %q", etag1, etag2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_DifferentETagsForDifferentContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Request same image at different sizes - should get different ETags
|
||||
req1 := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 25, Height: 25},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
req2 := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
resp1, err := svc.Get(ctx, req1)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
etag1 := resp1.ETag
|
||||
|
||||
_ = resp1.Content.Close()
|
||||
|
||||
resp2, err := svc.Get(ctx, req2)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() second request error = %v", err)
|
||||
}
|
||||
|
||||
etag2 := resp2.ETag
|
||||
|
||||
_ = resp2.Content.Close()
|
||||
|
||||
// ETags should be different for different content
|
||||
if etag1 == etag2 {
|
||||
t.Error("ETags should differ for different sizes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// widthOnlyRequest asks for the test photo at width, its height scaled to
|
||||
// keep the photo's aspect ratio.
|
||||
func widthOnlyRequest(fixtures *TestFixtures, width int) *imagerequest.Request {
|
||||
return &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: width},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// holdProcessingSlot takes one of proc's processing slots and returns the
|
||||
// func that gives it back. Process takes its slot before it reads its input,
|
||||
// so once it has read a byte from the pipe it holds the slot, until the pipe
|
||||
// is closed.
|
||||
func holdProcessingSlot(
|
||||
t *testing.T, proc *imageprocessor.ImageProcessor,
|
||||
) func() {
|
||||
t.Helper()
|
||||
|
||||
input, feed := io.Pipe()
|
||||
|
||||
go func() {
|
||||
_, _ = proc.Process(t.Context(), input, &imagerequest.Transform{})
|
||||
}()
|
||||
|
||||
_, err := feed.Write([]byte{0})
|
||||
if err != nil {
|
||||
t.Fatalf("Process call to hold the slot did not start: %v", err)
|
||||
}
|
||||
|
||||
release := func() { _ = feed.Close() }
|
||||
t.Cleanup(release)
|
||||
|
||||
return release
|
||||
}
|
||||
|
||||
// TestProxy_Get_WaitsForSlotBeforeReadingCachedSource checks that a
|
||||
// request whose source is cached holds none of it while it waits for a
|
||||
// processing slot: it reads the cached file only once it has a slot. With
|
||||
// the only slot held, a request for a new width of the cached 100x100 photo
|
||||
// waits; the cached file is then rewritten as a 100x50 image before the slot
|
||||
// is freed, so the request must answer with that image scaled to 40x20.
|
||||
func TestProxy_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
svc.processor = imageprocessor.New(
|
||||
imageprocessor.Params{MaxInputBytes: 50 << 20, MaxConcurrentProcessing: 1},
|
||||
)
|
||||
|
||||
// A first request caches the photo as a source.
|
||||
resp, err := svc.Get(t.Context(), widthOnlyRequest(fixtures, 50))
|
||||
if err != nil {
|
||||
t.Fatalf("first Get() error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
cachedSource := widthOnlyRequest(fixtures, 50).Source
|
||||
|
||||
reader, _, err := svc.cache.Source(t.Context(), cachedSource)
|
||||
if err != nil {
|
||||
t.Fatalf("Source() error = %v; want the cached source", err)
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
release := holdProcessingSlot(t, svc.processor)
|
||||
|
||||
var (
|
||||
waited *ImageResponse
|
||||
waitedErr error
|
||||
)
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
waited, waitedErr = svc.Get(t.Context(), widthOnlyRequest(fixtures, 40))
|
||||
}()
|
||||
|
||||
// Give the request time to reach the slot: had it read the cached source
|
||||
// before waiting, it would have read it by now.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
err = os.WriteFile(sourceFilePath(svc, cachedSource.Key()),
|
||||
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to rewrite the cached source: %v", err)
|
||||
}
|
||||
|
||||
release()
|
||||
<-done
|
||||
|
||||
if waitedErr != nil {
|
||||
t.Fatalf("Get() error = %v", waitedErr)
|
||||
}
|
||||
|
||||
defer func() { _ = waited.Content.Close() }()
|
||||
|
||||
output, err := jpeg.DecodeConfig(waited.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode the response: %v", err)
|
||||
}
|
||||
|
||||
if output.Width != 40 || output.Height != 20 {
|
||||
t.Errorf("response is %dx%d, want 40x20: the request read the cached "+
|
||||
"source before it had a processing slot", output.Width, output.Height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// TestProxy_Get_ServesCachedVariantOfNegativeCachedURL caches a variant of
|
||||
// the test photo, then keeps a failed fetch of the photo's URL in the negative
|
||||
// cache, as a failed fetch for another variant does once the source has been
|
||||
// evicted. The cached variant is still served, and a variant not in the cache
|
||||
// is made from the cached source. Once the source is evicted, a variant not in
|
||||
// the cache needs a fetch and is refused.
|
||||
func TestProxy_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
|
||||
resp, err := svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
if err != nil {
|
||||
t.Fatalf("Get() first request error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
err = svc.cache.PutFailure(t.Context(),
|
||||
photoVariant(fixtures, 85, imagerequest.FitCover).Source)
|
||||
if err != nil {
|
||||
t.Fatalf("PutFailure() error = %v", err)
|
||||
}
|
||||
|
||||
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
if err != nil {
|
||||
t.Fatalf("Get() of the cached variant error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
if resp.CacheStatus != CacheHit {
|
||||
t.Errorf("cached variant CacheStatus = %q, want %q",
|
||||
resp.CacheStatus, CacheHit)
|
||||
}
|
||||
|
||||
resp, err = svc.Get(t.Context(), photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||
t.Logf("Get() of a variant made from the cached source: error = %v", err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Get() of a variant made from the cached source error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
|
||||
|
||||
reader, _, err := svc.cache.Source(t.Context(), source)
|
||||
if err != nil {
|
||||
t.Fatalf("Source() error = %v, want the cached source", err)
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
// Evicts the source as the cache's eviction does: its row, then its file
|
||||
_, err = svc.db.ExecContext(t.Context(),
|
||||
`DELETE FROM source_content WHERE source_key = ?`, string(source.Key()))
|
||||
if err != nil {
|
||||
t.Fatalf("deleting the source's row: %v", err)
|
||||
}
|
||||
|
||||
err = os.Remove(sourceFilePath(svc, source.Key()))
|
||||
if err != nil {
|
||||
t.Fatalf("removing the source's file: %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Get(t.Context(), photoVariant(fixtures, 60, imagerequest.FitCover))
|
||||
t.Logf("Get() of a variant whose source was evicted: error = %v", err)
|
||||
|
||||
if !errors.Is(err, ErrNegativeCached) {
|
||||
t.Errorf("Get() of a variant whose source was evicted error = %v, want %v",
|
||||
err, ErrNegativeCached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxy_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This test verifies that Proxy.Get() checks the negative cache
|
||||
// We can't easily test the full pipeline without vips, but we can
|
||||
// verify the error type
|
||||
err := ErrNegativeCached
|
||||
if !errors.Is(err, ErrNegativeCached) {
|
||||
t.Error("ErrNegativeCached should be identifiable with errors.Is")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// getImage calls Get for req and returns the image it answered with and
|
||||
// its cache status.
|
||||
func getImage(
|
||||
t *testing.T, svc *testProxy, req *imagerequest.Request,
|
||||
) ([]byte, CacheStatus) {
|
||||
t.Helper()
|
||||
|
||||
resp, err := svc.Get(t.Context(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() of quality %d error = %v", req.Transform.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.Transform.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()
|
||||
}
|
||||
|
||||
// cachedSourcePath returns the path of the cached source file req uses.
|
||||
func cachedSourcePath(
|
||||
t *testing.T, svc *testProxy, req *imagerequest.Request,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
reader, _, err := svc.cache.Source(t.Context(), req.Source)
|
||||
if err != nil {
|
||||
t.Fatalf("Source() error = %v; want the cached source", err)
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
return sourceFilePath(svc, req.Source.Key())
|
||||
}
|
||||
|
||||
// TestProxy_Get_FetchesAgainForRemovedCachedSource removes the cached
|
||||
// source's row and file, as the reconciliation pass does when the file is
|
||||
// empty or cut short. internal/imgcache tests that the pass does so. The
|
||||
// test then asks for two new sizes of the source. The first must fetch it
|
||||
// again and cache it whole, so the second is made from the cache without a
|
||||
// third fetch.
|
||||
func TestProxy_Get_FetchesAgainForRemovedCachedSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
|
||||
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
source := photoVariant(fixtures, 85, imagerequest.FitCover).Source
|
||||
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
wholeSize := fileSize(t, path)
|
||||
|
||||
// Removes the source as the reconciliation pass does: its row, then its file
|
||||
_, err := svc.db.ExecContext(t.Context(),
|
||||
`DELETE FROM source_content WHERE source_key = ?`, string(source.Key()))
|
||||
if err != nil {
|
||||
t.Fatalf("deleting the source's row: %v", err)
|
||||
}
|
||||
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
t.Fatalf("removing the source's file: %v", err)
|
||||
}
|
||||
|
||||
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
||||
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
||||
|
||||
fetches := readCounters(t, svc).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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_ConvertsAgainForRemovedCachedVariant caches a variant of
|
||||
// the photo and removes its row and file, as the reconciliation pass does
|
||||
// when the file is empty or cut short. internal/imgcache tests that the
|
||||
// pass does so. The test then checks that Get converts the image again, and
|
||||
// that the next Get serves the whole variant from the cache.
|
||||
func TestProxy_Get_ConvertsAgainForRemovedCachedVariant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
|
||||
want, _ := getImage(t, svc, req)
|
||||
|
||||
key := imagerequest.CacheKey(req)
|
||||
|
||||
// Removes the variant as the reconciliation pass does: its row, then its file
|
||||
_, err := svc.db.ExecContext(t.Context(),
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(key))
|
||||
if err != nil {
|
||||
t.Fatalf("deleting the variant's row: %v", err)
|
||||
}
|
||||
|
||||
err = os.Remove(variantFilePath(svc, key))
|
||||
if err != nil {
|
||||
t.Fatalf("removing the variant's file: %v", err)
|
||||
}
|
||||
|
||||
got, status := getImage(t, svc, req)
|
||||
t.Logf("after the removal: %d bytes, %s", len(got), status)
|
||||
|
||||
if status != CacheMiss || !bytes.Equal(got, want) {
|
||||
t.Errorf("after the removal: 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// pendingAccessTimesWait is how long a test waits for the database's
|
||||
// connection, for a cache hit to be answered, for an access time to reach
|
||||
// the database, or for a request to return past its deadline.
|
||||
const pendingAccessTimesWait = 5 * time.Second
|
||||
|
||||
// holdDatabase takes the one connection of db, so that every other query
|
||||
// waits for it, and returns the func that frees it.
|
||||
func holdDatabase(t *testing.T, db *sql.DB) func() {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), pendingAccessTimesWait)
|
||||
defer cancel()
|
||||
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to take the database connection: %v", err)
|
||||
}
|
||||
|
||||
return func() { _ = conn.Close() }
|
||||
}
|
||||
|
||||
// TestProxy_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the
|
||||
// database's one connection while a request whose fetch is held reaches its
|
||||
// deadline. The request must return with the deadline's error while the
|
||||
// connection is still held, as it waits for no database write past its
|
||||
// deadline.
|
||||
func TestProxy_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchProxy(t)
|
||||
|
||||
svc.cache.StartPendingAccessTimeWrites()
|
||||
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
|
||||
|
||||
// Room for the request to reach its fetch on a busy host
|
||||
const timeout = 2 * time.Second
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), timeout)
|
||||
defer cancel()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
|
||||
results := startGet(ctx, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||
|
||||
// The request has made its database reads by the time it fetches.
|
||||
select {
|
||||
case <-fetcher.started:
|
||||
case <-time.After(timeout):
|
||||
t.Fatal("request did not reach its fetch by its deadline")
|
||||
}
|
||||
|
||||
releaseDatabase := holdDatabase(t, svc.db)
|
||||
defer releaseDatabase()
|
||||
|
||||
select {
|
||||
case got := <-results:
|
||||
t.Logf("Get() returned %v after its deadline, error = %v",
|
||||
time.Since(deadline), got.err)
|
||||
|
||||
if !errors.Is(got.err, context.DeadlineExceeded) {
|
||||
t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded)
|
||||
}
|
||||
case <-time.After(time.Until(deadline) + pendingAccessTimesWait):
|
||||
t.Fatal("request did not return while the database was busy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image/color"
|
||||
"io"
|
||||
"io/fs"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
// TestProxy_Get_CountsStats walks Get through a miss that fetches the
|
||||
// source, a hit, a miss that reuses the cached source, and two misses whose
|
||||
// source cannot be used, checking every counter of the proxy and its cache
|
||||
// after each.
|
||||
func TestProxy_Get_CountsStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
|
||||
// NewTestFS builds the same files the test proxy's fetcher serves.
|
||||
testFS, _ := NewTestFS(t)
|
||||
|
||||
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fake, err := fs.ReadFile(testFS, fixtures.InvalidFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
photoBytes, fakeBytes := int64(len(photo)), int64(len(fake))
|
||||
|
||||
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
||||
steps := []struct {
|
||||
name string
|
||||
path string
|
||||
size int
|
||||
wantErr bool
|
||||
want counterValues
|
||||
}{
|
||||
{"miss that fetches the source", testPathPhoto, 50, false,
|
||||
counterValues{0, 1, 1, photoBytes, 1}},
|
||||
{"hit", testPathPhoto, 50, false,
|
||||
counterValues{1, 1, 1, photoBytes, 1}},
|
||||
{"miss that reuses the cached source", testPathPhoto, 25, false,
|
||||
counterValues{1, 2, 1, photoBytes, 2}},
|
||||
{"miss whose source fails the magic byte check", "/images/fake.jpg", 50, true,
|
||||
counterValues{1, 3, 2, photoBytes + fakeBytes, 2}},
|
||||
{"miss whose source is not found", "/images/nonexistent.jpg", 50, true,
|
||||
counterValues{1, 4, 2, photoBytes + fakeBytes, 2}},
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
resp, err := svc.Get(t.Context(), &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: step.path,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: step.size, Height: step.size},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
})
|
||||
if (err != nil) != step.wantErr {
|
||||
t.Fatalf("%s: Get() error = %v, want error %t", step.name, err, step.wantErr)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
_ = resp.Content.Close()
|
||||
}
|
||||
|
||||
got := readCounters(t, svc)
|
||||
if got != step.want {
|
||||
t.Fatalf("after the %s: counters = %+v, want %+v", step.name, got, step.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeUpstream answers every fetch with itself as a JPEG body. The body
|
||||
// serves data, then calls cancel, when set, and returns err; io.EOF ends
|
||||
// the body normally.
|
||||
type fakeUpstream struct {
|
||||
data *bytes.Reader
|
||||
cancel context.CancelFunc
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *fakeUpstream) Fetch(
|
||||
context.Context, imagerequest.Source,
|
||||
) (*httpfetcher.FetchResult, error) {
|
||||
return &httpfetcher.FetchResult{
|
||||
Content: io.NopCloser(u),
|
||||
ContentLength: -1,
|
||||
ContentType: testContentTypeJPEG,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *fakeUpstream) Read(p []byte) (int, error) {
|
||||
if u.data.Len() > 0 {
|
||||
return u.data.Read(p)
|
||||
}
|
||||
|
||||
if u.cancel != nil {
|
||||
u.cancel()
|
||||
}
|
||||
|
||||
return 0, u.err
|
||||
}
|
||||
|
||||
// TestProxy_Get_CountsInterruptedMisses checks every counter of the proxy
|
||||
// and its cache after a miss whose request context ends during or after the
|
||||
// upstream fetch, and after a miss whose upstream body is over the size
|
||||
// limit.
|
||||
func TestProxy_Get_CountsInterruptedMisses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
photo := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||
half := len(photo) / 2
|
||||
|
||||
// want is hits, misses, upstream fetches, upstream bytes, transforms.
|
||||
tests := []struct {
|
||||
name string
|
||||
served int // bytes of the photo the upstream body serves
|
||||
cancel bool // whether the body then ends the request context
|
||||
readErr error // what the body then returns
|
||||
wantErr bool
|
||||
want counterValues
|
||||
}{
|
||||
{"request context ends during the fetch", half, true, context.Canceled, true,
|
||||
counterValues{0, 1, 1, int64(half), 0}},
|
||||
{"request context ends after the fetch", len(photo), true, io.EOF, false,
|
||||
counterValues{0, 1, 1, int64(len(photo)), 1}},
|
||||
{"upstream body over the size limit", half, false,
|
||||
httpfetcher.ErrResponseTooLarge, true,
|
||||
counterValues{0, 1, 1, int64(half), 0}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
upstream := &fakeUpstream{
|
||||
data: bytes.NewReader(photo[:tc.served]),
|
||||
err: tc.readErr,
|
||||
}
|
||||
if tc.cancel {
|
||||
upstream.cancel = cancel
|
||||
}
|
||||
|
||||
svc.fetcher = upstream
|
||||
|
||||
resp, err := svc.Get(ctx, &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
})
|
||||
t.Logf("Get() error = %v", err)
|
||||
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("Get() error = %v, want error %t", err, tc.wantErr)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
_ = resp.Content.Close()
|
||||
}
|
||||
|
||||
got := readCounters(t, svc)
|
||||
if got != tc.want {
|
||||
t.Errorf("counters = %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxy_Get_CountsHitAfterRequestEnds checks every counter of the
|
||||
// proxy and its cache after a hit served with a request context that has
|
||||
// already ended: only the hit count moves.
|
||||
func TestProxy_Get_CountsHitAfterRequestEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := setupTestProxy(t)
|
||||
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{
|
||||
Host: fixtures.GoodHost,
|
||||
Path: testPathPhoto,
|
||||
},
|
||||
Transform: imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 50, Height: 50},
|
||||
Format: imagerequest.FormatJPEG,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
},
|
||||
}
|
||||
|
||||
// A first request caches the variant.
|
||||
resp, err := svc.Get(t.Context(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("first Get() error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
want := readCounters(t, svc)
|
||||
want.hitCount++
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
resp, err = svc.Get(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() with an ended request context: error = %v", err)
|
||||
}
|
||||
|
||||
_ = resp.Content.Close()
|
||||
|
||||
if resp.CacheStatus != CacheHit {
|
||||
t.Fatalf("CacheStatus = %v, want %v", resp.CacheStatus, CacheHit)
|
||||
}
|
||||
|
||||
got := readCounters(t, svc)
|
||||
if got != want {
|
||||
t.Errorf("counters = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package imageproxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"sneak.berlin/go/pixa/internal/database"
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// Shared test data literals, extracted as constants for goconst.
|
||||
const (
|
||||
testContentTypeJPEG = "image/jpeg"
|
||||
)
|
||||
|
||||
// sqliteTimestampFormat matches the format SQLite's CURRENT_TIMESTAMP
|
||||
// produces, so injected timestamps compare correctly against ones the
|
||||
// cache writes.
|
||||
const sqliteTimestampFormat = "2006-01-02 15:04:05"
|
||||
|
||||
// TestFixtures contains paths to test files in the mock filesystem.
|
||||
type TestFixtures struct {
|
||||
// Valid image files
|
||||
GoodHostJPEG string // valid JPEG on GoodHost
|
||||
GoodHostPNG string // valid PNG on GoodHost
|
||||
GoodHostGIF string // valid GIF on GoodHost
|
||||
OtherHostJPEG string // valid JPEG on OtherHost
|
||||
OtherHostPNG string // valid PNG on OtherHost
|
||||
|
||||
// Invalid/edge case files
|
||||
InvalidFile string // file with wrong magic bytes
|
||||
EmptyFile string // zero-byte file
|
||||
TextFile string // text file masquerading as image
|
||||
|
||||
// Hostnames
|
||||
GoodHost string // a host the mock fetcher serves
|
||||
OtherHost string // a second host the mock fetcher serves
|
||||
}
|
||||
|
||||
// DefaultFixtures returns the standard test fixture paths.
|
||||
func DefaultFixtures() *TestFixtures {
|
||||
return &TestFixtures{
|
||||
GoodHostJPEG: "goodhost.example.com/images/photo.jpg",
|
||||
GoodHostPNG: "goodhost.example.com/images/logo.png",
|
||||
GoodHostGIF: "goodhost.example.com/images/animation.gif",
|
||||
OtherHostJPEG: "otherhost.example.com/uploads/image.jpg",
|
||||
OtherHostPNG: "otherhost.example.com/uploads/icon.png",
|
||||
InvalidFile: "goodhost.example.com/images/fake.jpg",
|
||||
EmptyFile: "goodhost.example.com/images/empty.jpg",
|
||||
TextFile: "goodhost.example.com/images/text.png",
|
||||
GoodHost: "goodhost.example.com",
|
||||
OtherHost: "otherhost.example.com",
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestFS creates a mock filesystem with test images.
|
||||
func NewTestFS(t *testing.T) (fs.FS, *TestFixtures) {
|
||||
t.Helper()
|
||||
|
||||
fixtures := DefaultFixtures()
|
||||
|
||||
// Generate test images
|
||||
jpegData := generateTestJPEG(t, 100, 100, color.RGBA{255, 0, 0, 255})
|
||||
pngData := generateTestPNG(t, 100, 100, color.RGBA{0, 255, 0, 255})
|
||||
gifData := generateTestGIF(t, 100, 100, color.RGBA{0, 0, 255, 255})
|
||||
|
||||
// Create the mock filesystem
|
||||
mockFS := fstest.MapFS{
|
||||
// Good host files
|
||||
fixtures.GoodHostJPEG: &fstest.MapFile{Data: jpegData},
|
||||
fixtures.GoodHostPNG: &fstest.MapFile{Data: pngData},
|
||||
fixtures.GoodHostGIF: &fstest.MapFile{Data: gifData},
|
||||
|
||||
// Other host files
|
||||
fixtures.OtherHostJPEG: &fstest.MapFile{Data: jpegData},
|
||||
fixtures.OtherHostPNG: &fstest.MapFile{Data: pngData},
|
||||
|
||||
// Invalid files
|
||||
fixtures.InvalidFile: &fstest.MapFile{Data: []byte("not a real image file content")},
|
||||
fixtures.EmptyFile: &fstest.MapFile{Data: []byte{}},
|
||||
fixtures.TextFile: &fstest.MapFile{Data: []byte("This is just text, not a PNG")},
|
||||
}
|
||||
|
||||
return mockFS, fixtures
|
||||
}
|
||||
|
||||
// generateTestJPEG creates a minimal valid JPEG image.
|
||||
func generateTestJPEG(t *testing.T, width, height int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test JPEG: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// generateTestPNG creates a minimal valid PNG image.
|
||||
func generateTestPNG(t *testing.T, width, height int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := png.Encode(&buf, img)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test PNG: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// generateTestGIF creates a minimal valid GIF image.
|
||||
func generateTestGIF(t *testing.T, width, height int, c color.Color) []byte {
|
||||
t.Helper()
|
||||
|
||||
img := image.NewPaletted(
|
||||
image.Rect(0, 0, width, height),
|
||||
[]color.Color{c, color.White},
|
||||
)
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
img.SetColorIndex(x, y, 0)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
err := gif.Encode(&buf, img, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to encode test GIF: %v", err)
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// testProxy is a Proxy with the database and the state directory of its
|
||||
// cache, through which a test reads and changes the cache behind the proxy.
|
||||
// registry is the metrics registry the proxy and its cache share.
|
||||
type testProxy struct {
|
||||
*Proxy
|
||||
|
||||
db *sql.DB
|
||||
stateDir string
|
||||
registry *prometheus.Registry
|
||||
}
|
||||
|
||||
// setupTestProxy creates a Proxy with mock fetcher for testing.
|
||||
func setupTestProxy(t *testing.T) (*testProxy, *TestFixtures) {
|
||||
t.Helper()
|
||||
|
||||
mockFS, fixtures := NewTestFS(t)
|
||||
|
||||
// Create temp directory for cache
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create in-memory database
|
||||
db := setupTestDB(t)
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
cache, err := imgcache.NewCache(db, imgcache.Config{
|
||||
StateDir: tmpDir,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
MaxBytes: 1 << 30,
|
||||
}, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
}
|
||||
|
||||
proxy, err := New(&Config{
|
||||
Cache: cache,
|
||||
Fetcher: httpfetcher.NewMock(mockFS),
|
||||
Processor: imageprocessor.New(imageprocessor.Params{
|
||||
MaxInputBytes: 50 << 20,
|
||||
MaxConcurrentProcessing: 2,
|
||||
}),
|
||||
MaxResponseSize: 50 << 20,
|
||||
}, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create proxy: %v", err)
|
||||
}
|
||||
|
||||
return &testProxy{
|
||||
Proxy: proxy,
|
||||
db: db,
|
||||
stateDir: tmpDir,
|
||||
registry: registry,
|
||||
}, fixtures
|
||||
}
|
||||
|
||||
// counterValues holds the values of the counters of a proxy and its cache.
|
||||
type counterValues struct {
|
||||
hitCount int64
|
||||
missCount int64
|
||||
upstreamFetchCount int64
|
||||
upstreamFetchBytes int64
|
||||
transformCount int64
|
||||
}
|
||||
|
||||
// readCounters reads the counters of svc and its cache from their metrics
|
||||
// registry.
|
||||
func readCounters(t *testing.T, svc *testProxy) counterValues {
|
||||
t.Helper()
|
||||
|
||||
families, err := svc.registry.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to gather the counters: %v", err)
|
||||
}
|
||||
|
||||
values := make(map[string]int64, len(families))
|
||||
|
||||
for _, family := range families {
|
||||
values[family.GetName()] = int64(family.GetMetric()[0].GetCounter().GetValue())
|
||||
}
|
||||
|
||||
return counterValues{
|
||||
hitCount: values["pixa_cache_hits_total"],
|
||||
missCount: values["pixa_cache_misses_total"],
|
||||
upstreamFetchCount: values["pixa_upstream_fetches_total"],
|
||||
upstreamFetchBytes: values["pixa_upstream_fetch_bytes_total"],
|
||||
transformCount: values["pixa_transforms_total"],
|
||||
}
|
||||
}
|
||||
|
||||
// sourceFilePath returns the path of the file of the source key in svc's
|
||||
// cache, as README.md gives it: <state_dir>/cache/sources/<ab>/<cd>/<key>.
|
||||
func sourceFilePath(svc *testProxy, key imagerequest.SourceKey) string {
|
||||
name := string(key)
|
||||
|
||||
return filepath.Join(svc.stateDir, "cache", "sources", name[0:2], name[2:4], name)
|
||||
}
|
||||
|
||||
// variantFilePath returns the path of the file of the variant key in svc's
|
||||
// cache, as README.md gives it: <state_dir>/cache/variants/<ab>/<cd>/<key>.
|
||||
func variantFilePath(svc *testProxy, key imagerequest.VariantKey) string {
|
||||
name := string(key)
|
||||
|
||||
return filepath.Join(svc.stateDir, "cache", "variants", name[0:2], name[2:4], name)
|
||||
}
|
||||
|
||||
// setVariantLastAccessed backdates the last access time of a tracked
|
||||
// variant.
|
||||
func setVariantLastAccessed(
|
||||
t *testing.T, db *sql.DB, key imagerequest.VariantKey, when time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
res, err := db.ExecContext(t.Context(),
|
||||
`UPDATE variant_content SET last_accessed_at = ? WHERE cache_key = ?`,
|
||||
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set variant last_accessed_at: %v", err)
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read affected rows: %v", err)
|
||||
}
|
||||
|
||||
if affected != 1 {
|
||||
t.Fatalf("variant %s has no accounting row (affected=%d); "+
|
||||
"stores must track variants in the database", key, affected)
|
||||
}
|
||||
}
|
||||
|
||||
// setSourceLastAccessed backdates the last access time of a tracked
|
||||
// source content blob.
|
||||
func setSourceLastAccessed(
|
||||
t *testing.T, db *sql.DB, key imagerequest.SourceKey, when time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
res, err := db.ExecContext(t.Context(),
|
||||
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
|
||||
when.UTC().Format(sqliteTimestampFormat), string(key),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||
}
|
||||
|
||||
affected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read affected rows: %v", err)
|
||||
}
|
||||
|
||||
if affected != 1 {
|
||||
t.Fatalf("source %s has no accounting row (affected=%d)", key, affected)
|
||||
}
|
||||
}
|
||||
|
||||
// countRows returns the number of rows the given query yields.
|
||||
func countRows(t *testing.T, db *sql.DB, query string, args ...any) int {
|
||||
t.Helper()
|
||||
|
||||
var n int
|
||||
|
||||
err := db.QueryRowContext(t.Context(), query, args...).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatalf("count query %q failed: %v", query, err)
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// setupTestDB creates an in-memory SQLite database for testing
|
||||
// using the production schema.
|
||||
func setupTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test db: %v", err)
|
||||
}
|
||||
|
||||
// Use the real production schema via migrations
|
||||
err = database.ApplyMigrations(context.Background(), db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to apply migrations: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
Reference in New Issue
Block a user