Files
pixa/internal/imgcache/partial_cache_files_internal_test.go
T
clawbot ac850823f4
check / check (push) Canceled after 0s
Keep cached sources and variants in one FileStore type (closes #318)
imgcache.FileStore is a directory of files named by a hex key, at
<ab>/<cd>/<key>. It writes a file under a temp name, syncs it and renames
it into place. It opens, reports the size of and deletes the file for a
key. The cache keeps its sources in one FileStore and its variants in
another, so ContentStorage and VariantStorage are gone. FileStore has no
Exists: the reconciliation pass checks a variant's file with Size, and the
tests look for the file on disk. Cache.Variant reads the content type from
the file's first bytes itself. storage.go is now filestore.go, and the
unused testdata/red.avif is gone.

Model: opus-5-5
2026-10-26 20:24:33 +01:00

191 lines
5.0 KiB
Go

package imgcache
import (
"bytes"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// 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)
}
// TestReconciliationRemovesDamagedFiles empties a cached source file and a
// cached variant file, or cuts them to half their size, as a crash or a
// restored snapshot could. It checks that the reconciliation pass removes
// each file and its row, with a warning naming the file.
func TestReconciliationRemovesDamagedFiles(t *testing.T) {
t.Parallel()
content := []byte("the whole cached image")
for name, size := range map[string]int64{
"empty": 0,
"partial": int64(len(content) / 2),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
sourceKey := storeEvictionTestSource(t, cache, testHostCDN, testPathCat, content)
storeEvictionTestVariant(t, cache, testVariantKeyOne, content)
sourcePath := cache.srcContent.keyToPath(string(sourceKey))
variantPath := cache.variants.keyToPath(string(testVariantKeyOne))
for _, path := range []string{sourcePath, variantPath} {
err := os.Truncate(path, size)
if err != nil {
t.Fatalf("cutting %s to %d bytes: %v", path, size, err)
}
}
logs := reconcileAsAtStartup(t, cache)
assertWarningNames(t, logs, sourcePath)
assertWarningNames(t, logs, variantPath)
_, err := os.Stat(sourcePath)
if err == nil {
t.Error("the damaged source file is still on disk")
}
_, err = os.Stat(variantPath)
if err == nil {
t.Error("the damaged variant file is still on disk")
}
rows := countRows(t, cache,
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`,
string(sourceKey))
if rows != 0 {
t.Errorf("the damaged source file kept %d rows, want 0", rows)
}
rows = countRows(t, cache,
`SELECT COUNT(*) FROM variant_content WHERE cache_key = ?`,
string(testVariantKeyOne))
if rows != 0 {
t.Errorf("the damaged variant file kept %d rows, want 0", rows)
}
})
}
}
// 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.Write(string(testVariantKeyOne), nil)
if err != nil {
t.Fatalf("failed to store an empty variant file: %v", err)
}
variantPath := cache.variants.keyToPath(string(testVariantKeyOne))
logs := reconcileAsAtStartup(t, cache)
assertWarningNames(t, logs, variantPath)
_, err = os.Stat(variantPath)
if err == nil {
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)
}
}
// TestFileStore_WriteReplacesFileOfAnotherSize checks that a write 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 TestFileStore_WriteReplacesFileOfAnotherSize(t *testing.T) {
t.Parallel()
storage, err := NewFileStore(t.TempDir())
if err != nil {
t.Fatalf("NewFileStore() error = %v", err)
}
content := []byte("the whole source image")
key := string(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.Write(key, content)
if err != nil {
t.Fatalf("Write() error = %v", err)
}
stored, _, err := storage.Open(key)
if err != nil {
t.Fatalf("Open() 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)
}
}