Serve a cache hit with no database read or write (closes #309)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A hit opens the variant file and nothing else: its content type comes from the file's first bytes, and a file in no output format is a miss. Its access time and hit count, and the access time of a cached source a miss uses, go without waiting to the goroutine that wrote late counts, which now writes them all once a second; each kind of access time waits in a channel of 10,000 that drops the rest. checkNegativeCache leaves expired rows to CleanExpired. The .meta files, metaCache, Lookup and golang-lru go, with their tests. Dropping the .meta skip made the variant and source reconciliation walks identical, so they share one. make bench runs a parallel cache hit benchmark on the host, outside make check. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/imageprocessor"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
"sneak.berlin/go/pixa/internal/magic"
|
||||
)
|
||||
|
||||
// TestGetVariant_ContentTypeFromFirstBytes stores an image in each output
|
||||
// format as a variant, recorded with the content type text/plain, and checks
|
||||
// that GetVariant serves it whole with the content type of its format, read
|
||||
// from the file.
|
||||
func TestGetVariant_ContentTypeFromFirstBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
processor := imageprocessor.New(imageprocessor.Params{})
|
||||
source := generateTestJPEG(t, 40, 30, color.RGBA{200, 40, 40, 255})
|
||||
|
||||
for _, format := range []imagerequest.Format{
|
||||
imagerequest.FormatJPEG, imagerequest.FormatPNG, imagerequest.FormatGIF,
|
||||
imagerequest.FormatWebP, imagerequest.FormatAVIF, imagerequest.FormatJXL,
|
||||
} {
|
||||
result, err := processor.Process(t.Context(), bytes.NewReader(source),
|
||||
&imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 20, Height: 15},
|
||||
Format: format,
|
||||
Quality: 85,
|
||||
FitMode: imagerequest.FitCover,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("making a %s image: %v", format, err)
|
||||
}
|
||||
|
||||
image, err := io.ReadAll(result.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("reading the %s image: %v", format, err)
|
||||
}
|
||||
|
||||
key := imagerequest.VariantKey("aabbccdd10" + string(format))
|
||||
|
||||
err = cache.StoreVariant(t.Context(), key, bytes.NewReader(image), "text/plain")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreVariant(%s) error = %v", format, err)
|
||||
}
|
||||
|
||||
reader, size, contentType, err := cache.GetVariant(key)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVariant(%s) error = %v", format, err)
|
||||
}
|
||||
|
||||
served, err := io.ReadAll(reader)
|
||||
_ = reader.Close()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("reading the %s variant: %v", format, err)
|
||||
}
|
||||
|
||||
t.Logf("%s: %d bytes served as %s", format, size, contentType)
|
||||
|
||||
if want := imageprocessor.FormatToMIME(format); contentType != want {
|
||||
t.Errorf("%s variant content type = %q, want %q", format, contentType, want)
|
||||
}
|
||||
|
||||
if size != int64(len(image)) || !bytes.Equal(served, image) {
|
||||
t.Errorf("%s variant served %d bytes, size %d, want the %d bytes stored",
|
||||
format, len(served), size, len(image))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetVariant_RefusesFileOfNoOutputFormat checks that GetVariant refuses
|
||||
// a variant file whose first bytes are not those of an output format, with
|
||||
// an error other than ErrNotFound, and that a variant with no file, or any
|
||||
// variant of a disabled cache, is ErrNotFound.
|
||||
func TestGetVariant_RefusesFileOfNoOutputFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
jpegImage := generateTestJPEG(t, 10, 10, color.RGBA{0, 0, 255, 255})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content []byte
|
||||
wantErr error
|
||||
}{
|
||||
{"text", []byte("this is text, not an image"), magic.ErrUnknownFormat},
|
||||
{"SVG", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||
errNotAnOutputFormat},
|
||||
{"JPEG cut to 10 bytes", jpegImage[:10], io.EOF},
|
||||
{"empty", nil, io.EOF},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
key := imagerequest.VariantKey(fmt.Sprintf("aabbccdd20%02d", i))
|
||||
|
||||
_, err := cache.variants.Store(key, bytes.NewReader(tt.content))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: storing the variant file: %v", tt.name, err)
|
||||
}
|
||||
|
||||
reader, _, contentType, err := cache.GetVariant(key)
|
||||
t.Logf("%s: GetVariant() error = %v", tt.name, err)
|
||||
|
||||
if reader != nil || contentType != "" || !errors.Is(err, tt.wantErr) {
|
||||
t.Errorf("%s: GetVariant() = reader %v, content type %q, error %v; "+
|
||||
"want no reader and %v", tt.name, reader, contentType, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
reader, _, _, err := cache.GetVariant(testVariantKeyFour)
|
||||
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("GetVariant() of a variant with no file = reader %v, error %v; "+
|
||||
"want no reader and %v", reader, err, ErrNotFound)
|
||||
}
|
||||
|
||||
disabled, _ := newEvictionTestCache(t, 0)
|
||||
|
||||
reader, _, _, err = disabled.GetVariant(testVariantKeyOne)
|
||||
if reader != nil || !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("GetVariant() on a disabled cache = reader %v, error %v; "+
|
||||
"want no reader and %v", reader, err, ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_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 TestService_Get_MakesAgainVariantOfNoOutputFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
|
||||
want, _ := getImage(t, svc, req)
|
||||
|
||||
path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
|
||||
|
||||
err := os.WriteFile(path, []byte("this is text, not an image"), 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_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 and hit count
|
||||
// must reach the database once it is free.
|
||||
func TestService_Get_HitWaitsForNoDatabaseWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc, fixtures := SetupTestService(t)
|
||||
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
||||
key := imagerequest.CacheKey(req)
|
||||
|
||||
getImage(t, svc, req)
|
||||
waitForPendingCountsWritten(t, svc.cache)
|
||||
|
||||
anHourAgo := time.Now().Add(-time.Hour)
|
||||
setVariantLastAccessed(t, svc.cache, key, anHourAgo)
|
||||
|
||||
want := readCacheStatsCounters(t, svc.cache)
|
||||
want.hitCount++
|
||||
|
||||
releaseDatabase := holdDatabase(t, svc.cache)
|
||||
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(pendingCountsWait):
|
||||
t.Fatal("the cache hit waited for the database")
|
||||
}
|
||||
|
||||
releaseDatabase()
|
||||
|
||||
waitForCounters(t, svc.cache, want)
|
||||
|
||||
deadline := time.Now().Add(pendingCountsWait)
|
||||
|
||||
for countRows(t, svc.cache, `
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit hands one
|
||||
// more access time of a variant, and one more of a source, than may wait to
|
||||
// be written. touchVariant and touchSourceContent must return each time
|
||||
// without waiting, and the last of each must be dropped.
|
||||
func TestTouchVariantAndTouchSourceContent_DropAccessTimesPastTheLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
for range maxPendingAccessTimes + 1 {
|
||||
cache.touchVariant(testVariantKeyOne)
|
||||
cache.touchSourceContent("aabbccdd3001")
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(pendingCountsWait):
|
||||
t.Fatal("touchVariant or touchSourceContent waited once the access " +
|
||||
"times reached the limit")
|
||||
}
|
||||
|
||||
if waiting := len(cache.pendingVariantAccessTimes); waiting != maxPendingAccessTimes {
|
||||
t.Errorf("%d access times of variants wait to be written, want %d",
|
||||
waiting, maxPendingAccessTimes)
|
||||
}
|
||||
|
||||
if waiting := len(cache.pendingSourceAccessTimes); waiting != maxPendingAccessTimes {
|
||||
t.Errorf("%d access times of sources wait to be written, want %d",
|
||||
waiting, maxPendingAccessTimes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopPendingCountWrites_WritesPendingAccessTimes hands the access time
|
||||
// of one of two variants, three times, then stops the goroutine that writes
|
||||
// them, which never started. The stop must write that access time and
|
||||
// leave the other variant's.
|
||||
func TestStopPendingCountWrites_WritesPendingAccessTimes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
anHourAgo := time.Now().Add(-time.Hour)
|
||||
|
||||
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
|
||||
storeEvictionTestVariant(t, cache, key, []byte("variant bytes"))
|
||||
setVariantLastAccessed(t, cache, key, anHourAgo)
|
||||
}
|
||||
|
||||
for range 3 {
|
||||
cache.touchVariant(testVariantKeyOne)
|
||||
}
|
||||
|
||||
err := cache.StopPendingCountWrites(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("StopPendingCountWrites() error = %v", err)
|
||||
}
|
||||
|
||||
for key, wantTouched := range map[imagerequest.VariantKey]int{
|
||||
testVariantKeyOne: 1, testVariantKeyTwo: 0,
|
||||
} {
|
||||
touched := countRows(t, cache, `
|
||||
SELECT COUNT(*) FROM variant_content
|
||||
WHERE cache_key = ? AND last_accessed_at > ?
|
||||
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat))
|
||||
if touched != wantTouched {
|
||||
t.Errorf("variant %s: %d rows with a new access time, want %d",
|
||||
key, touched, wantTouched)
|
||||
}
|
||||
}
|
||||
|
||||
if waiting := len(cache.pendingVariantAccessTimes); waiting != 0 {
|
||||
t.Errorf("%d access times still wait to be written, want 0", waiting)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user