check / check (push) Canceled after 0s
The five counts the cache_stats table held are now Prometheus counters in memory, in internal/imgcache/counters.go, on the default registry that /metrics serves. The Increment methods add to them, so counting makes no database call. cache_stats, Cache.Stats, Service.Stats, CacheStats and the pending counts are gone, with their tests. The background goroutine now writes only access times, and its names say so: StartPendingAccessTimeWrites, StopPendingAccessTimeWrites and pendingaccesstimes.go. The counters count for the whole process, so the tests that check how much they rose do not call t.Parallel. Two stop tests whose write under way was a hit count now use an access time. Model: opus-5-5
301 lines
9.3 KiB
Go
301 lines
9.3 KiB
Go
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 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))
|
|
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 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)
|
|
|
|
// 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.cache.db.SetMaxOpenConns(1)
|
|
svc.cache.StartPendingAccessTimeWrites()
|
|
|
|
defer func() { _ = svc.cache.StopPendingAccessTimeWrites(t.Context()) }()
|
|
|
|
anHourAgo := time.Now().Add(-time.Hour)
|
|
setVariantLastAccessed(t, svc.cache, key, anHourAgo)
|
|
|
|
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(pendingAccessTimesWait):
|
|
t.Fatal("the cache hit waited for the database")
|
|
}
|
|
|
|
releaseDatabase()
|
|
|
|
deadline := time.Now().Add(pendingAccessTimesWait)
|
|
|
|
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(pendingAccessTimesWait):
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestStopPendingAccessTimeWrites_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 TestStopPendingAccessTimeWrites_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.StopPendingAccessTimeWrites(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("StopPendingAccessTimeWrites() 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)
|
|
}
|
|
}
|