check / check (push) Failing after 2m48s
A read that finds a variant in memory but cannot open its file removes it from memory. When that happens after StoreVariant has added the variant's content type, a read that opened the variant file before the store wrote its .meta file finds memory empty and keeps application/octet-stream there, so every later hit serves the image with it. The test runs the store, the failed read and the read without a .meta file in that order, then checks a later hit. It fails now. Model: opus-5-5
301 lines
8.4 KiB
Go
301 lines
8.4 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// webpRequest returns a request for a 100x100 WebP variant of path.
|
|
func webpRequest(path string) *ImageRequest {
|
|
return &ImageRequest{
|
|
SourceHost: testHostCDN,
|
|
SourcePath: path,
|
|
Size: Size{Width: 100, Height: 100},
|
|
Format: FormatWebP,
|
|
Quality: 85,
|
|
FitMode: FitCover,
|
|
}
|
|
}
|
|
|
|
// assertVariantServed checks that GetVariant serves key with the given
|
|
// content and the image/webp content type storeEvictionTestVariant stores.
|
|
func assertVariantServed(t *testing.T, cache *Cache, key VariantKey, content []byte) {
|
|
t.Helper()
|
|
|
|
reader, size, contentType, err := cache.GetVariant(key)
|
|
if err != nil {
|
|
t.Fatalf("GetVariant(%s) error = %v", key, err)
|
|
}
|
|
|
|
defer func() { _ = reader.Close() }()
|
|
|
|
got, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatalf("reading variant %s: %v", key, err)
|
|
}
|
|
|
|
if !bytes.Equal(got, content) {
|
|
t.Errorf("GetVariant(%s) content = %q, want %q", key, got, content)
|
|
}
|
|
|
|
if size != int64(len(content)) {
|
|
t.Errorf("GetVariant(%s) size = %d, want %d", key, size, len(content))
|
|
}
|
|
|
|
if contentType != testContentTypeWebP {
|
|
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
key, contentType, testContentTypeWebP)
|
|
}
|
|
}
|
|
|
|
// assertVariantNotFound checks that GetVariant refuses key with
|
|
// ErrNotFound.
|
|
func assertVariantNotFound(t *testing.T, cache *Cache, key VariantKey) {
|
|
t.Helper()
|
|
|
|
reader, _, _, err := cache.GetVariant(key)
|
|
if err == nil {
|
|
_ = reader.Close()
|
|
}
|
|
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("GetVariant(%s) error = %v, want ErrNotFound", key, err)
|
|
}
|
|
}
|
|
|
|
// assertLookupMisses checks that Lookup reports request as a miss.
|
|
func assertLookupMisses(t *testing.T, cache *Cache, request *ImageRequest) {
|
|
t.Helper()
|
|
|
|
lookup, err := cache.Lookup(t.Context(), request)
|
|
if err != nil {
|
|
t.Fatalf("Lookup(%s) error = %v", request.SourcePath, err)
|
|
}
|
|
|
|
if lookup.Hit {
|
|
t.Errorf("Lookup(%s) is a hit, want a miss", request.SourcePath)
|
|
}
|
|
}
|
|
|
|
// renameFile renames the file at from to to.
|
|
func renameFile(t *testing.T, from, to string) {
|
|
t.Helper()
|
|
|
|
err := os.Rename(from, to)
|
|
if err != nil {
|
|
t.Fatalf("renaming %s: %v", from, err)
|
|
}
|
|
}
|
|
|
|
// TestSecondHitDoesNotReadMetaFile checks that once a variant has been
|
|
// stored or read, a hit takes its content type from memory: with the
|
|
// .meta file deleted, GetVariant must still return the stored content
|
|
// type rather than the application/octet-stream it uses without one.
|
|
func TestSecondHitDoesNotReadMetaFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
content := []byte("webp variant bytes")
|
|
|
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
|
|
// A second Cache on the same state directory starts with nothing in
|
|
// memory, as pixad does after a restart, so its first read uses the
|
|
// .meta file.
|
|
restarted, err := NewCache(cache.db, cache.config)
|
|
if err != nil {
|
|
t.Fatalf("NewCache() error = %v", err)
|
|
}
|
|
|
|
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
|
|
err = os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
if err != nil {
|
|
t.Fatalf("removing .meta file: %v", err)
|
|
}
|
|
|
|
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
assertVariantServed(t, restarted, testVariantKeyOne, content)
|
|
}
|
|
|
|
// TestReadDuringStoreKeepsStoredContentType checks that a GetVariant
|
|
// which began before StoreVariant finished cannot replace the content
|
|
// type the store kept in memory. Such a read can find the variant file
|
|
// but not yet its .meta file, and so gets application/octet-stream. The
|
|
// test deletes the .meta file after the store, then runs the part of
|
|
// GetVariant that comes after its check of memory.
|
|
func TestReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
content := []byte("webp variant bytes")
|
|
|
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
|
|
err := os.Remove(cache.variants.keyToPath(testVariantKeyOne) + ".meta")
|
|
if err != nil {
|
|
t.Fatalf("removing .meta file: %v", err)
|
|
}
|
|
|
|
reader, _, contentType, err := cache.loadVariantWithMeta(testVariantKeyOne)
|
|
if err != nil {
|
|
t.Fatalf("loadVariantWithMeta(%s) error = %v", testVariantKeyOne, err)
|
|
}
|
|
|
|
_ = reader.Close()
|
|
|
|
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
|
|
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
}
|
|
|
|
// TestFailedReadDuringStoreKeepsStoredContentType checks that a read
|
|
// which found no .meta file cannot leave application/octet-stream in
|
|
// memory, even when another read has removed the content type
|
|
// StoreVariant kept there. In this order: the store; a read that finds
|
|
// the variant in memory but cannot open its file, and so removes it
|
|
// from memory; a read that opened the variant file before the store
|
|
// wrote its .meta file. Later hits must get the stored content type.
|
|
func TestFailedReadDuringStoreKeepsStoredContentType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
content := []byte("webp variant bytes")
|
|
variantPath := cache.variants.keyToPath(testVariantKeyOne)
|
|
metaPath := variantPath + ".meta"
|
|
|
|
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
|
|
|
|
renameFile(t, variantPath, variantPath+".hidden")
|
|
assertVariantNotFound(t, cache, testVariantKeyOne)
|
|
renameFile(t, variantPath+".hidden", variantPath)
|
|
|
|
renameFile(t, metaPath, metaPath+".hidden")
|
|
|
|
reader, _, contentType, err := cache.GetVariant(testVariantKeyOne)
|
|
if err != nil {
|
|
t.Fatalf("GetVariant(%s) error = %v", testVariantKeyOne, err)
|
|
}
|
|
|
|
_ = reader.Close()
|
|
|
|
t.Logf("the read without a .meta file got content type %q", contentType)
|
|
|
|
renameFile(t, metaPath+".hidden", metaPath)
|
|
|
|
assertVariantServed(t, cache, testVariantKeyOne, content)
|
|
}
|
|
|
|
// TestEvictedVariantIsNotServed checks that a variant the evictor
|
|
// removed is a miss and cannot be read, although it had been stored
|
|
// and served before.
|
|
func TestEvictedVariantIsNotServed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1500)
|
|
|
|
oldRequest := webpRequest("/old.jpg")
|
|
newRequest := webpRequest("/new.jpg")
|
|
oldKey := CacheKey(oldRequest)
|
|
newKey := CacheKey(newRequest)
|
|
oldContent := bytes.Repeat([]byte{0x01}, 1000)
|
|
newContent := bytes.Repeat([]byte{0x02}, 1000)
|
|
|
|
storeEvictionTestVariant(t, cache, oldKey, oldContent)
|
|
storeEvictionTestVariant(t, cache, newKey, newContent)
|
|
assertVariantServed(t, cache, oldKey, oldContent)
|
|
setVariantLastAccessed(t, cache, oldKey, time.Now().Add(-time.Hour))
|
|
|
|
err := cache.EvictToLimit(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("EvictToLimit() error = %v", err)
|
|
}
|
|
|
|
assertLookupMisses(t, cache, oldRequest)
|
|
assertVariantNotFound(t, cache, oldKey)
|
|
assertVariantServed(t, cache, newKey, newContent)
|
|
}
|
|
|
|
// TestVariantDeletedFromDiskIsNotServed checks that a variant whose
|
|
// file was deleted by something other than the evictor cannot be read,
|
|
// and is a miss afterwards.
|
|
func TestVariantDeletedFromDiskIsNotServed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
|
|
request := webpRequest("/deleted.jpg")
|
|
key := CacheKey(request)
|
|
content := []byte("webp variant bytes")
|
|
|
|
storeEvictionTestVariant(t, cache, key, content)
|
|
assertVariantServed(t, cache, key, content)
|
|
|
|
err := os.Remove(cache.variants.keyToPath(key))
|
|
if err != nil {
|
|
t.Fatalf("removing variant file: %v", err)
|
|
}
|
|
|
|
assertVariantNotFound(t, cache, key)
|
|
assertLookupMisses(t, cache, request)
|
|
}
|
|
|
|
// TestConcurrentVariantStoreReadAndEvict stores, reads and evicts
|
|
// variants from several goroutines at once, for the race detector.
|
|
func TestConcurrentVariantStoreReadAndEvict(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<20)
|
|
ctx := t.Context()
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
for goroutine := range 8 {
|
|
wg.Go(func() {
|
|
key := VariantKey(fmt.Sprintf("aabbccdd01%02d", goroutine))
|
|
content := []byte(key)
|
|
|
|
for range 20 {
|
|
err := cache.StoreVariant(
|
|
ctx, key, bytes.NewReader(content), testContentTypeWebP)
|
|
if err != nil {
|
|
t.Errorf("StoreVariant(%s) error = %v", key, err)
|
|
|
|
return
|
|
}
|
|
|
|
reader, _, contentType, err := cache.GetVariant(key)
|
|
if err != nil {
|
|
t.Errorf("GetVariant(%s) error = %v", key, err)
|
|
|
|
return
|
|
}
|
|
|
|
_ = reader.Close()
|
|
|
|
if contentType != testContentTypeWebP {
|
|
t.Errorf("GetVariant(%s) content type = %q, want %q",
|
|
key, contentType, testContentTypeWebP)
|
|
}
|
|
|
|
err = cache.evictVariant(ctx, key)
|
|
if err != nil {
|
|
t.Errorf("evictVariant(%s) error = %v", key, err)
|
|
|
|
return
|
|
}
|
|
|
|
assertVariantNotFound(t, cache, key)
|
|
}
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|