1 Commits
Author SHA1 Message Date
sneak 2dd2134f7f Store a source the way a variant is stored, one row and one file per source URL (closes #308)
check / check (push) Canceled after 0s
A source is now one source_content row and one file named by its
SourceKey, the SHA-256 of its host, path and query, made as the variant
key is. A store writes the file, then upserts the row, and removes the
file when the row cannot be written; an eviction deletes the row, then
the file, with no lock. source_metadata, the JSON files under
cache/metadata/, MetadataStorage, the content lock, the eviction
transaction and its test hook are gone, as is the reconciliation pass
that dropped a source row whose file is missing. Two source URLs
serving the same bytes store them twice.

Model: opus-5-5
2026-10-21 14:16:47 +00:00
7 changed files with 149 additions and 33 deletions

No files matched your search

@@ -31,8 +31,8 @@ func TestEvictionReadsTheUsageTotal(t *testing.T) {
storeEvictionTestVariant(t, cache, testVariantKeyTwo, storeEvictionTestVariant(t, cache, testVariantKeyTwo,
bytes.Repeat([]byte{0x75}, 200)) bytes.Repeat([]byte{0x75}, 200))
// pixa never changes a source's size, but a statement that does must // Storing a source URL again changes its size, so the total must
// change the total too. // follow a statement that changes it.
_, err := cache.db.ExecContext(ctx, _, err := cache.db.ExecContext(ctx,
`UPDATE source_content SET size_bytes = 900 WHERE source_key = ?`, `UPDATE source_content SET size_bytes = 900 WHERE source_key = ?`,
string(kept)) string(kept))
+19 -16
View File
@@ -948,14 +948,15 @@ func TestStopEvictionInterruptsPassInProgress(t *testing.T) {
} }
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context // TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
// while the oldest of three sources is being evicted, and checks that // once the oldest of three sources is evicted, while the next one waits
// EvictToLimit then returns context.Canceled, with that source gone and // for the database, and checks that EvictToLimit then returns
// the other two kept, and logs no warning. The test database has one // context.Canceled, with that source gone and the other two kept, and
// connection. When the deletion of the oldest source's row commits, a // logs no warning. The test database has one connection. When the
// commit hook holds the deletion until the test waits for the // deletion of the oldest source's row commits, a commit hook holds the
// connection, so the test takes it as soon as the deletion is done. The // deletion until the test waits for the connection, so the test takes it
// eviction of the next source then waits for the connection, and the // as soon as the deletion is done. The eviction of the next source then
// test cancels the context while it waits. // waits for the connection, and the test cancels the context while it
// waits.
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) { func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1006,24 +1007,26 @@ func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
go func() { evicted <- cache.EvictToLimit(ctx) }() go func() { evicted <- cache.EvictToLimit(ctx) }()
<-oldestRowDeleted select {
case <-oldestRowDeleted:
conn, err := cache.db.Conn(t.Context()) case err := <-evicted:
if err != nil { t.Fatalf("EvictToLimit() returned %v without deleting a row", err)
t.Fatalf("failed to take the database connection: %v", err) case <-time.After(5 * time.Second):
t.Fatal("EvictToLimit never deleted a row")
} }
defer func() { _ = conn.Close() }() releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitForConnectionWaits(t, cache.db, waitsBefore+2) waitForConnectionWaits(t, cache.db, waitsBefore+2)
cancel() cancel()
err = <-evicted err := <-evicted
t.Logf("EvictToLimit() error = %v", err) t.Logf("EvictToLimit() error = %v", err)
t.Logf("log output: %s", logBuf.String()) t.Logf("log output: %s", logBuf.String())
// The checks below read the database, so the connection goes back. // The checks below read the database, so the connection goes back.
_ = conn.Close() releaseDatabase()
setCommitHook(t, cache, nil) setCommitHook(t, cache, nil)
@@ -103,7 +103,7 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
// before waiting, it would have read it by now. // before waiting, it would have read it by now.
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
err = os.WriteFile(svc.cache.srcContent.hashToPath(sourceKey), err = os.WriteFile(svc.cache.srcContent.keyToPath(sourceKey),
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600) generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
if err != nil { if err != nil {
t.Fatalf("failed to rewrite the cached source: %v", err) t.Fatalf("failed to rewrite the cached source: %v", err)
@@ -70,7 +70,7 @@ func cachedSourcePath(
t.Fatalf("LookupSource() = %q, %v; want the cached source", sourceKey, err) t.Fatalf("LookupSource() = %q, %v; want the cached source", sourceKey, err)
} }
return svc.cache.srcContent.hashToPath(sourceKey) return svc.cache.srcContent.keyToPath(sourceKey)
} }
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does // reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
@@ -327,7 +327,7 @@ func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
content := []byte("the whole source image") content := []byte("the whole source image")
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key() key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
path := storage.hashToPath(key) path := storage.keyToPath(key)
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm) err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
if err != nil { if err != nil {
+1 -1
View File
@@ -619,7 +619,7 @@ func backdateSource(
dbTime, string(key)) dbTime, string(key))
setSourceLastAccessed(t, cache, key, when) setSourceLastAccessed(t, cache, key, when)
path := cache.srcContent.hashToPath(key) path := cache.srcContent.keyToPath(key)
err := os.Chtimes(path, when, when) err := os.Chtimes(path, when, when)
if err != nil { if err != nil {
+11 -11
View File
@@ -49,7 +49,7 @@ func NewContentStorage(baseDir string) (*ContentStorage, error) {
func (s *ContentStorage) Store( func (s *ContentStorage) Store(
key imagerequest.SourceKey, data []byte, key imagerequest.SourceKey, data []byte,
) (int64, error) { ) (int64, error) {
path := s.hashToPath(key) path := s.keyToPath(key)
err := os.MkdirAll(filepath.Dir(path), StorageDirPerm) err := os.MkdirAll(filepath.Dir(path), StorageDirPerm)
if err != nil { if err != nil {
@@ -66,7 +66,7 @@ func (s *ContentStorage) Store(
// Load returns a reader for the content with the given key. // Load returns a reader for the content with the given key.
func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error) { func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error) {
path := s.hashToPath(key) path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from source key f, err := os.Open(path) //nolint:gosec // path derived from source key
if err != nil { if err != nil {
@@ -84,7 +84,7 @@ func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error)
func (s *ContentStorage) LoadWithSize( func (s *ContentStorage) LoadWithSize(
key imagerequest.SourceKey, key imagerequest.SourceKey,
) (io.ReadCloser, int64, error) { ) (io.ReadCloser, int64, error) {
path := s.hashToPath(key) path := s.keyToPath(key)
f, err := os.Open(path) //nolint:gosec // path derived from source key f, err := os.Open(path) //nolint:gosec // path derived from source key
if err != nil { if err != nil {
@@ -107,7 +107,7 @@ func (s *ContentStorage) LoadWithSize(
// Delete removes content with the given key. // Delete removes content with the given key.
func (s *ContentStorage) Delete(key imagerequest.SourceKey) error { func (s *ContentStorage) Delete(key imagerequest.SourceKey) error {
path := s.hashToPath(key) path := s.keyToPath(key)
err := os.Remove(path) err := os.Remove(path)
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
@@ -119,7 +119,7 @@ func (s *ContentStorage) Delete(key imagerequest.SourceKey) error {
// Exists checks if content with the given key exists. // Exists checks if content with the given key exists.
func (s *ContentStorage) Exists(key imagerequest.SourceKey) bool { func (s *ContentStorage) Exists(key imagerequest.SourceKey) bool {
path := s.hashToPath(key) path := s.keyToPath(key)
_, err := os.Stat(path) _, err := os.Stat(path)
return err == nil return err == nil
@@ -199,14 +199,14 @@ func syncDirectory(dir string) error {
return nil return nil
} }
// hashToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key> // keyToPath converts a key to a file path: <basedir>/<ab>/<cd>/<key>
func (s *ContentStorage) hashToPath(key imagerequest.SourceKey) string { func (s *ContentStorage) keyToPath(key imagerequest.SourceKey) string {
h := string(key) k := string(key)
if len(h) < MinHashLength { if len(k) < MinHashLength {
return filepath.Clean(filepath.Join(s.baseDir, h)) return filepath.Clean(filepath.Join(s.baseDir, k))
} }
return filepath.Clean(filepath.Join(s.baseDir, h[0:2], h[2:4], h)) return filepath.Clean(filepath.Join(s.baseDir, k[0:2], k[2:4], k))
} }
// VariantStorage handles key-based file storage for processed image variants. // VariantStorage handles key-based file storage for processed image variants.
+113
View File
@@ -3,14 +3,66 @@ package imgcache
import ( import (
"bytes" "bytes"
"errors" "errors"
"io"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/pixa/internal/imagerequest" "sneak.berlin/go/pixa/internal/imagerequest"
) )
func TestContentStorage_StoreAndLoad(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
content := []byte("hello world")
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
size, err := storage.Store(key, content)
if err != nil {
t.Fatalf("Store() error = %v", err)
}
if size != int64(len(content)) {
t.Errorf("Store() size = %d, want %d", size, len(content))
}
// Verify file exists at expected path
keyStr := string(key)
expectedPath := filepath.Join(tmpDir, keyStr[0:2], keyStr[2:4], keyStr)
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err)
}
// Load and verify content
r, err := storage.Load(key)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
defer func() { _ = r.Close() }()
loaded, err := io.ReadAll(r)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if !bytes.Equal(loaded, content) {
t.Errorf("Load() content = %q, want %q", loaded, content)
}
}
func TestContentStorage_LoadNotFound(t *testing.T) { func TestContentStorage_LoadNotFound(t *testing.T) {
t.Parallel() t.Parallel()
@@ -27,6 +79,38 @@ func TestContentStorage_LoadNotFound(t *testing.T) {
} }
} }
func TestContentStorage_Delete(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
content := []byte("to be deleted")
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
_, err = storage.Store(key, content)
if err != nil {
t.Fatalf("Store() error = %v", err)
}
if !storage.Exists(key) {
t.Error("Exists() = false, want true")
}
err = storage.Delete(key)
if err != nil {
t.Fatalf("Delete() error = %v", err)
}
if storage.Exists(key) {
t.Error("Exists() = true after delete, want false")
}
}
func TestContentStorage_DeleteNonexistent(t *testing.T) { func TestContentStorage_DeleteNonexistent(t *testing.T) {
t.Parallel() t.Parallel()
@@ -44,6 +128,35 @@ func TestContentStorage_DeleteNonexistent(t *testing.T) {
} }
} }
func TestContentStorage_HashToPath(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
storage, err := NewContentStorage(tmpDir)
if err != nil {
t.Fatalf("NewContentStorage() error = %v", err)
}
// Test by storing and verifying the resulting path structure
content := []byte("test content for path verification")
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
_, err = storage.Store(key, content)
if err != nil {
t.Fatalf("Store() error = %v", err)
}
keyStr := string(key)
expectedPath := filepath.Join(tmpDir, keyStr[0:2], keyStr[2:4], keyStr)
_, err = os.Stat(expectedPath)
if err != nil {
t.Errorf("File not at expected path %s: %v", expectedPath, err)
}
}
// TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write // TestVariantStorage_StoreLogsFailedMetaWrite verifies that a .meta write
// that fails is logged at warn and the store still succeeds. // that fails is logged at warn and the store still succeeds.
func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) { func TestVariantStorage_StoreLogsFailedMetaWrite(t *testing.T) {