check / check (push) Waiting to run
The five counts the cache_stats table held are now Prometheus counters in memory, as fields of the cache. NewCache makes them and registers them on the registry it is given. pixad gives it Prometheus' default registry, which /metrics serves; each test gives it a new one. The Increment methods add to their cache's counters, 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 two stop tests whose write under way was a count now use access times. Model: opus-5-5
309 lines
8.9 KiB
Go
309 lines
8.9 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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 *Service, 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()
|
|
}
|
|
|
|
// truncateFile cuts the file at path down to size bytes.
|
|
func truncateFile(t *testing.T, path string, size int64) {
|
|
t.Helper()
|
|
|
|
t.Logf("cutting %s from %d to %d bytes",
|
|
filepath.Base(path), fileSize(t, path), size)
|
|
|
|
err := os.Truncate(path, size)
|
|
if err != nil {
|
|
t.Fatalf("truncating %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// cachedSourcePath returns the path of the cached source file req uses.
|
|
func cachedSourcePath(
|
|
t *testing.T, svc *Service, req *imagerequest.Request,
|
|
) string {
|
|
t.Helper()
|
|
|
|
sourceKey, err := svc.cache.LookupSource(t.Context(), req)
|
|
if err != nil || sourceKey == "" {
|
|
t.Fatalf("LookupSource() = %q, %v; want the cached source", sourceKey, err)
|
|
}
|
|
|
|
return svc.cache.srcContent.keyToPath(sourceKey)
|
|
}
|
|
|
|
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
|
// when it starts, and returns what the pass logged.
|
|
func reconcileAsAtStartup(t *testing.T, cache *Cache) string {
|
|
t.Helper()
|
|
|
|
var logBuf bytes.Buffer
|
|
|
|
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
|
|
|
err := cache.reconcileAccounting(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("reconcileAccounting() error = %v", err)
|
|
}
|
|
|
|
t.Logf("reconciliation log: %s", logBuf.String())
|
|
|
|
return logBuf.String()
|
|
}
|
|
|
|
// assertWarningNames checks that logs hold a warning naming the file at
|
|
// path.
|
|
func assertWarningNames(t *testing.T, logs, path string) {
|
|
t.Helper()
|
|
|
|
for line := range strings.Lines(logs) {
|
|
if strings.Contains(line, `"level":"WARN"`) &&
|
|
strings.Contains(line, `"path":"`+path+`"`) {
|
|
return
|
|
}
|
|
}
|
|
|
|
t.Errorf("reconciliation logged no warning naming %s", path)
|
|
}
|
|
|
|
// TestService_Get_FetchesAgainForEmptyCachedSource empties the cached source
|
|
// file, as a crash or a restored snapshot could, and runs the reconciliation
|
|
// pass, which must remove it with a warning naming it. It then asks for two
|
|
// new sizes of the source: the first must fetch it again and cache it whole,
|
|
// so that the second is made from the cache without a third fetch.
|
|
func TestService_Get_FetchesAgainForEmptyCachedSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
|
|
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
|
|
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
|
wholeSize := fileSize(t, path)
|
|
|
|
truncateFile(t, path, 0)
|
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
|
|
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
|
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
|
|
|
fetches := readCounters(svc.cache).upstreamFetchCount
|
|
t.Logf("upstream fetches = %d", fetches)
|
|
|
|
if fetches != 2 {
|
|
t.Errorf("upstream fetches = %d, want 2: the empty cached source "+
|
|
"was kept and fetched again on every new size", fetches)
|
|
}
|
|
|
|
size := fileSize(t, path)
|
|
if size != wholeSize {
|
|
t.Errorf("cached source is %d bytes, want %d", size, wholeSize)
|
|
}
|
|
}
|
|
|
|
// TestService_Get_FetchesAgainForPartialCachedSource cuts the cached source
|
|
// file to half its size, as a crash or a restored snapshot could, and runs
|
|
// the reconciliation pass, which must remove it with a warning naming it.
|
|
// It then asks for two new sizes of the source: the first must fetch it
|
|
// again rather than fail to decode the partial file, and the second is
|
|
// made from the cache.
|
|
func TestService_Get_FetchesAgainForPartialCachedSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
|
|
getImage(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
|
|
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, imagerequest.FitCover))
|
|
wholeSize := fileSize(t, path)
|
|
|
|
truncateFile(t, path, wholeSize/2)
|
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
|
|
|
getImage(t, svc, photoVariant(fixtures, 40, imagerequest.FitCover))
|
|
getImage(t, svc, photoVariant(fixtures, 30, imagerequest.FitCover))
|
|
|
|
fetches := readCounters(svc.cache).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)
|
|
}
|
|
}
|
|
|
|
// assertConvertedAgain caches a variant of the photo, cuts its file to size
|
|
// bytes and runs the reconciliation pass, which must remove it with a
|
|
// warning naming it. It then checks that Get converts the image again
|
|
// instead of serving the file, and that the next Get serves the whole
|
|
// variant from the cache.
|
|
func assertConvertedAgain(t *testing.T, size int64) {
|
|
t.Helper()
|
|
|
|
svc, fixtures := SetupTestService(t)
|
|
req := photoVariant(fixtures, 85, imagerequest.FitCover)
|
|
|
|
want, _ := getImage(t, svc, req)
|
|
|
|
path := svc.cache.variants.keyToPath(imagerequest.CacheKey(req))
|
|
|
|
truncateFile(t, path, size)
|
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
|
|
|
got, status := getImage(t, svc, req)
|
|
t.Logf("after the cut: %d bytes, %s", len(got), status)
|
|
|
|
if status != CacheMiss || !bytes.Equal(got, want) {
|
|
t.Errorf("after the cut: 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)
|
|
}
|
|
}
|
|
|
|
// TestService_Get_ConvertsAgainForEmptyCachedVariant empties a cached
|
|
// variant file, as a crash or a restored snapshot could, and checks that it
|
|
// is converted again rather than served empty.
|
|
func TestService_Get_ConvertsAgainForEmptyCachedVariant(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertConvertedAgain(t, 0)
|
|
}
|
|
|
|
// TestService_Get_ConvertsAgainForPartialCachedVariant cuts a cached
|
|
// variant file to ten bytes, as a crash or a restored snapshot could, and
|
|
// checks that it is converted again rather than served cut short.
|
|
func TestService_Get_ConvertsAgainForPartialCachedVariant(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertConvertedAgain(t, 10)
|
|
}
|
|
|
|
// TestReconciliationRemovesEmptyVariantFileWithoutRow checks that the
|
|
// reconciliation pass removes an empty variant file that has no
|
|
// accounting row, with a warning naming it, instead of adding a row for
|
|
// it that would have it served empty.
|
|
func TestReconciliationRemovesEmptyVariantFileWithoutRow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
|
|
_, err := cache.variants.Store(testVariantKeyOne, bytes.NewReader(nil))
|
|
if err != nil {
|
|
t.Fatalf("failed to store an empty variant file: %v", err)
|
|
}
|
|
|
|
logs := reconcileAsAtStartup(t, cache)
|
|
|
|
assertWarningNames(t, logs, cache.variants.keyToPath(testVariantKeyOne))
|
|
|
|
if cache.variants.Exists(testVariantKeyOne) {
|
|
t.Error("the empty variant file is still on disk")
|
|
}
|
|
|
|
rows := countRows(t, cache,
|
|
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`,
|
|
string(testVariantKeyOne))
|
|
if rows != 0 {
|
|
t.Errorf("the empty variant file got %d accounting rows, want 0", rows)
|
|
}
|
|
}
|
|
|
|
// TestContentStorage_StoreReplacesFileOfAnotherSize checks that a
|
|
// store over a file of another size at the source's path, such as a
|
|
// partial file a crash left, replaces it with the content.
|
|
func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
storage, err := NewContentStorage(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewContentStorage() error = %v", err)
|
|
}
|
|
|
|
content := []byte("the whole source image")
|
|
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
|
|
path := storage.keyToPath(key)
|
|
|
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
|
if err != nil {
|
|
t.Fatalf("creating the source's directory: %v", err)
|
|
}
|
|
|
|
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
|
if err != nil {
|
|
t.Fatalf("writing a partial file: %v", err)
|
|
}
|
|
|
|
_, err = storage.Store(key, content)
|
|
if err != nil {
|
|
t.Fatalf("Store() error = %v", err)
|
|
}
|
|
|
|
stored, err := storage.Load(key)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
defer func() { _ = stored.Close() }()
|
|
|
|
got, err := io.ReadAll(stored)
|
|
if err != nil {
|
|
t.Fatalf("reading the stored content: %v", err)
|
|
}
|
|
|
|
if !bytes.Equal(got, content) {
|
|
t.Errorf("stored file = %q, want %q", got, content)
|
|
}
|
|
}
|