Share one fetch and transcode among concurrent misses (closes #65) #160
@@ -0,0 +1,444 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"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, url string,
|
||||
) (*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, url)
|
||||
}
|
||||
|
||||
// releaseFetches lets every held fetch, and every later one, go on.
|
||||
func (f *heldFetcher) releaseFetches() {
|
||||
f.releaseOnce.Do(func() { close(f.release) })
|
||||
}
|
||||
|
||||
// setupHeldFetchService returns a test service 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 setupHeldFetchService(t *testing.T) (*Service, *TestFixtures, *heldFetcher) {
|
||||
t.Helper()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
svc.cache.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, as Get writes
|
||||
// to the request it is given.
|
||||
func photoVariant(fixtures *TestFixtures, quality int, fit FitMode) *ImageRequest {
|
||||
return &ImageRequest{
|
||||
SourceHost: fixtures.GoodHost,
|
||||
SourcePath: testPathPhoto,
|
||||
Size: Size{Width: 50, Height: 25},
|
||||
Format: 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 *Service, req *ImageRequest,
|
||||
) <-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
|
||||
}
|
||||
|
||||
// TestService_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 TestService_Get_ConcurrentMissesShareOneFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||
|
||||
const requests = 8
|
||||
|
||||
pending := make([]<-chan getResult, 0, requests)
|
||||
for range requests {
|
||||
pending = append(pending,
|
||||
startGet(t.Context(), svc, photoVariant(fixtures, 85, 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 service's fetcher serves.
|
||||
testFS, _ := NewTestFS(t)
|
||||
|
||||
photo, err := fs.ReadFile(testFS, fixtures.GoodHostJPEG)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := cacheStatsCounters{0, requests, 1, int64(len(photo)), 1}
|
||||
|
||||
if got := readCacheStatsCounters(t, svc.cache); got != want {
|
||||
t.Errorf("counters = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_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 TestService_Get_ConcurrentVariantsStayApart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||
|
||||
cover := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
||||
lowQuality := startGet(t.Context(), svc, photoVariant(fixtures, 40, FitCover))
|
||||
contain := startGet(t.Context(), svc, photoVariant(fixtures, 85, 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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_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 TestService_Get_WaiterStopsWhenItsContextEnds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||
|
||||
first := startGet(t.Context(), svc, photoVariant(fixtures, 85, FitCover))
|
||||
|
||||
<-fetcher.started
|
||||
|
||||
waiterCtx, cancelWaiter := context.WithCancel(t.Context())
|
||||
waiter := startGet(waiterCtx, svc, photoVariant(fixtures, 85, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_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 TestService_Get_FirstRequestLeavingKeepsTheWork(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||
|
||||
firstCtx, cancelFirst := context.WithCancel(t.Context())
|
||||
first := startGet(firstCtx, svc, photoVariant(fixtures, 85, FitCover))
|
||||
|
||||
<-fetcher.started
|
||||
|
||||
second := startGet(t.Context(), svc, photoVariant(fixtures, 85, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_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 when the failure is kept there, and fetches again when it
|
||||
// is not.
|
||||
func TestService_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, not kept",
|
||||
"/images/text.png", magic.ErrUnknownFormat, magic.ErrUnknownFormat, 2},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||
|
||||
request := func() *ImageRequest {
|
||||
req := photoVariant(fixtures, 85, FitCover)
|
||||
req.SourcePath = 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_Get_EndedRequestFetchesNothing checks that a request whose
|
||||
// context has already ended when it misses the cache starts no fetch.
|
||||
func TestService_Get_EndedRequestFetchesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures, fetcher := setupHeldFetchService(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
_, err := svc.Get(ctx, photoVariant(fixtures, 85, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// panickingFetcher panics on every fetch.
|
||||
type panickingFetcher struct{}
|
||||
|
||||
func (panickingFetcher) Fetch(
|
||||
context.Context, string,
|
||||
) (*httpfetcher.FetchResult, error) {
|
||||
panic("upstream fetcher panicked")
|
||||
}
|
||||
|
||||
// TestService_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 TestService_Get_PanicBecomesAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(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, FitCover))
|
||||
}()
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "upstream fetcher panicked") {
|
||||
t.Errorf("Get() error = %v, want one naming the panic", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user