The tests of an empty or cut cached source or variant now run the reconciliation pass, as pixa does at startup, before the next request, and check that it logs a warning naming the file. New tests: the pass removes an empty variant file without an accounting row instead of adopting it, and VariantStorage.Store puts a new .meta file in place of the old one rather than writing into it. #252 Model: opus-5-5
360 lines
10 KiB
Go
360 lines
10 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// 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) ([]byte, CacheStatus) {
|
|
t.Helper()
|
|
|
|
resp, err := svc.Get(t.Context(), req)
|
|
if err != nil {
|
|
t.Fatalf("Get() of quality %d error = %v", req.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.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) string {
|
|
t.Helper()
|
|
|
|
contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
|
|
if err != nil || contentHash == "" {
|
|
t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err)
|
|
}
|
|
|
|
return svc.cache.srcContent.hashToPath(contentHash)
|
|
}
|
|
|
|
// 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, FitCover))
|
|
|
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
|
wholeSize := fileSize(t, path)
|
|
|
|
truncateFile(t, path, 0)
|
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
|
|
|
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
|
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
|
|
|
fetches := readCacheStatsCounters(t, 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, FitCover))
|
|
|
|
path := cachedSourcePath(t, svc, photoVariant(fixtures, 85, FitCover))
|
|
wholeSize := fileSize(t, path)
|
|
|
|
truncateFile(t, path, wholeSize/2)
|
|
assertWarningNames(t, reconcileAsAtStartup(t, svc.cache), path)
|
|
|
|
getImage(t, svc, photoVariant(fixtures, 40, FitCover))
|
|
getImage(t, svc, photoVariant(fixtures, 30, FitCover))
|
|
|
|
fetches := readCacheStatsCounters(t, 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, FitCover)
|
|
|
|
want, _ := getImage(t, svc, req)
|
|
|
|
path := svc.cache.variants.keyToPath(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),
|
|
testContentTypeWebP)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestVariantStorage_StoreReplacesMetaFile stores a variant over one
|
|
// already stored and checks that Store puts a new .meta file in place of
|
|
// the old one, as it does the image, rather than writing into the old
|
|
// file, which a crash could leave empty. The old .meta file is given a
|
|
// second name before the store, so that it can be told apart from the new
|
|
// one after it.
|
|
func TestVariantStorage_StoreReplacesMetaFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
storage, key := storeTestVariant(t)
|
|
metaPath := storage.keyToPath(key) + variantMetaSuffix
|
|
oldMetaPath := metaPath + ".old"
|
|
|
|
err := os.Link(metaPath, oldMetaPath)
|
|
if err != nil {
|
|
t.Fatalf("giving the .meta file a second name: %v", err)
|
|
}
|
|
|
|
_, err = storage.Store(key, bytes.NewReader([]byte("new variant data")),
|
|
testContentTypeJPEG)
|
|
if err != nil {
|
|
t.Fatalf("Store() error = %v", err)
|
|
}
|
|
|
|
oldMeta, err := os.Stat(oldMetaPath)
|
|
if err != nil {
|
|
t.Fatalf("stat of the old .meta file: %v", err)
|
|
}
|
|
|
|
newMeta, err := os.Stat(metaPath)
|
|
if err != nil {
|
|
t.Fatalf("stat of the new .meta file: %v", err)
|
|
}
|
|
|
|
if os.SameFile(oldMeta, newMeta) {
|
|
t.Error("Store() wrote into the old .meta file instead of putting " +
|
|
"a new one in its place")
|
|
}
|
|
|
|
reader, _, contentType, err := storage.LoadWithMeta(key)
|
|
if err != nil {
|
|
t.Fatalf("LoadWithMeta() error = %v", err)
|
|
}
|
|
|
|
_ = reader.Close()
|
|
|
|
t.Logf("content type after the store: %s", contentType)
|
|
|
|
if contentType != testContentTypeJPEG {
|
|
t.Errorf("content type = %q, want %q", contentType, testContentTypeJPEG)
|
|
}
|
|
}
|
|
|
|
// TestContentStorage_StoreHashedReplacesFileOfAnotherSize checks that a
|
|
// store over a file of another size at the content's path, such as a
|
|
// partial file a crash left, replaces it with the content.
|
|
func TestContentStorage_StoreHashedReplacesFileOfAnotherSize(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")
|
|
sum := sha256.Sum256(content)
|
|
hash := ContentHash(hex.EncodeToString(sum[:]))
|
|
path := storage.hashToPath(hash)
|
|
|
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
|
if err != nil {
|
|
t.Fatalf("creating the content's directory: %v", err)
|
|
}
|
|
|
|
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
|
if err != nil {
|
|
t.Fatalf("writing a partial file: %v", err)
|
|
}
|
|
|
|
_, err = storage.StoreHashed(hash, content)
|
|
if err != nil {
|
|
t.Fatalf("StoreHashed() error = %v", err)
|
|
}
|
|
|
|
stored, err := storage.Load(hash)
|
|
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)
|
|
}
|
|
}
|