Add type-safe hash types for cache storage
Define ContentHash, VariantKey, and PathHash types to replace raw strings, providing compile-time type safety for storage operations. Update storage layer to use typed parameters, refactor cache to use variant storage keyed by VariantKey, and implement source content reuse on cache misses.
This commit is contained in:
@@ -99,11 +99,9 @@ func setupTestCache(t *testing.T) (*Cache, string) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
HotCacheSize: 100,
|
||||
HotCacheEnabled: true,
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
@@ -168,17 +166,12 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
||||
t.Error("StoreSource() returned empty hash")
|
||||
}
|
||||
|
||||
// Get source metadata ID
|
||||
metaID, err := cache.GetSourceMetadataID(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSourceMetadataID() error = %v", err)
|
||||
}
|
||||
|
||||
// Store output content
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("fake webp data")
|
||||
_, err = cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp")
|
||||
err = cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreOutput() error = %v", err)
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
}
|
||||
|
||||
// Now lookup should hit
|
||||
@@ -195,8 +188,8 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
||||
t.Errorf("Lookup() status = %v, want %v", result.CacheStatus, CacheHit)
|
||||
}
|
||||
|
||||
if result.OutputHash == "" {
|
||||
t.Error("Lookup() returned empty output hash")
|
||||
if result.CacheKey == "" {
|
||||
t.Error("Lookup() returned empty cache key")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,10 +210,14 @@ func TestCache_NegativeCache(t *testing.T) {
|
||||
t.Fatalf("StoreNegative() error = %v", err)
|
||||
}
|
||||
|
||||
// Lookup should return negative cache error
|
||||
_, err = cache.Lookup(ctx, req)
|
||||
if err != ErrNegativeCache {
|
||||
t.Errorf("Lookup() error = %v, want ErrNegativeCache", err)
|
||||
// Lookup should return miss (negative cache is checked at service level)
|
||||
result, err := cache.Lookup(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() error = %v", err)
|
||||
}
|
||||
|
||||
if result.Hit {
|
||||
t.Error("Lookup() hit = true, want false for negative cache entry")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,11 +227,9 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||
|
||||
// Very short negative TTL for testing
|
||||
cache, err := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 1 * time.Millisecond,
|
||||
HotCacheSize: 100,
|
||||
HotCacheEnabled: true,
|
||||
StateDir: tmpDir,
|
||||
CacheTTL: time.Hour,
|
||||
NegativeTTL: 1 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cache: %v", err)
|
||||
@@ -258,7 +253,7 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||
// Wait for expiry
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Lookup should return miss (not negative cache) after expiry
|
||||
// Lookup should return miss after expiry
|
||||
result, err := cache.Lookup(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() error = %v, want nil after expiry", err)
|
||||
@@ -269,40 +264,28 @@ func TestCache_NegativeCacheExpiry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_HotCache(t *testing.T) {
|
||||
func TestCache_VariantLookup(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/hot.jpg",
|
||||
SourcePath: "/photos/variant.jpg",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
Quality: 85,
|
||||
FitMode: FitCover,
|
||||
}
|
||||
|
||||
// Store content
|
||||
sourceContent := []byte("source data")
|
||||
fetchResult := &FetchResult{
|
||||
ContentType: "image/jpeg",
|
||||
Headers: map[string][]string{},
|
||||
}
|
||||
|
||||
_, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||
if err != nil {
|
||||
t.Fatalf("StoreSource() error = %v", err)
|
||||
}
|
||||
|
||||
metaID, _ := cache.GetSourceMetadataID(ctx, req)
|
||||
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("output data")
|
||||
_, err = cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp")
|
||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreOutput() error = %v", err)
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
}
|
||||
|
||||
// First lookup populates hot cache
|
||||
// First lookup
|
||||
result1, err := cache.Lookup(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() first error = %v", err)
|
||||
@@ -312,7 +295,7 @@ func TestCache_HotCache(t *testing.T) {
|
||||
t.Error("Lookup() first hit = false")
|
||||
}
|
||||
|
||||
// Second lookup should use hot cache
|
||||
// Second lookup should also hit (from disk)
|
||||
result2, err := cache.Lookup(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() second error = %v", err)
|
||||
@@ -322,66 +305,59 @@ func TestCache_HotCache(t *testing.T) {
|
||||
t.Error("Lookup() second hit = false")
|
||||
}
|
||||
|
||||
if result1.OutputHash != result2.OutputHash {
|
||||
t.Error("Lookup() returned different hashes")
|
||||
if result1.CacheKey != result2.CacheKey {
|
||||
t.Error("Lookup() returned different cache keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_HotCache_ReturnsContentType(t *testing.T) {
|
||||
func TestCache_GetVariant_ReturnsContentType(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
req := &ImageRequest{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photos/hotct.jpg",
|
||||
SourcePath: "/photos/variantct.jpg",
|
||||
Size: Size{Width: 800, Height: 600},
|
||||
Format: FormatWebP,
|
||||
Quality: 85,
|
||||
FitMode: FitCover,
|
||||
}
|
||||
|
||||
// Store content
|
||||
sourceContent := []byte("source data")
|
||||
fetchResult := &FetchResult{
|
||||
ContentType: "image/jpeg",
|
||||
Headers: map[string][]string{},
|
||||
}
|
||||
|
||||
_, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||
if err != nil {
|
||||
t.Fatalf("StoreSource() error = %v", err)
|
||||
}
|
||||
|
||||
metaID, _ := cache.GetSourceMetadataID(ctx, req)
|
||||
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("output webp data")
|
||||
_, err = cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp")
|
||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreOutput() error = %v", err)
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
}
|
||||
|
||||
// First lookup populates hot cache
|
||||
result1, err := cache.Lookup(ctx, req)
|
||||
// Lookup
|
||||
result, err := cache.Lookup(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() first error = %v", err)
|
||||
t.Fatalf("Lookup() error = %v", err)
|
||||
}
|
||||
|
||||
if result1.ContentType != "image/webp" {
|
||||
t.Errorf("Lookup() first ContentType = %q, want %q", result1.ContentType, "image/webp")
|
||||
if !result.Hit {
|
||||
t.Fatal("Lookup() hit = false, want true")
|
||||
}
|
||||
|
||||
// Second lookup uses hot cache - must still have ContentType
|
||||
result2, err := cache.Lookup(ctx, req)
|
||||
// GetVariant should return content type
|
||||
reader, size, contentType, err := cache.GetVariant(result.CacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() second error = %v", err)
|
||||
t.Fatalf("GetVariant() error = %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
if contentType != "image/webp" {
|
||||
t.Errorf("GetVariant() ContentType = %q, want %q", contentType, "image/webp")
|
||||
}
|
||||
|
||||
if result2.ContentType != "image/webp" {
|
||||
t.Errorf("Lookup() hot cache ContentType = %q, want %q", result2.ContentType, "image/webp")
|
||||
if size != int64(len(outputContent)) {
|
||||
t.Errorf("GetVariant() size = %d, want %d", size, len(outputContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GetOutput(t *testing.T) {
|
||||
func TestCache_GetVariant(t *testing.T) {
|
||||
cache, _ := setupTestCache(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -394,38 +370,24 @@ func TestCache_GetOutput(t *testing.T) {
|
||||
FitMode: FitCover,
|
||||
}
|
||||
|
||||
// Store content
|
||||
sourceContent := []byte("source")
|
||||
fetchResult := &FetchResult{ContentType: "image/jpeg", Headers: map[string][]string{}}
|
||||
_, err := cache.StoreSource(ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||
if err != nil {
|
||||
t.Fatalf("StoreSource() error = %v", err)
|
||||
}
|
||||
|
||||
metaID, err := cache.GetSourceMetadataID(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSourceMetadataID() error = %v", err)
|
||||
}
|
||||
|
||||
// Store variant
|
||||
cacheKey := CacheKey(req)
|
||||
outputContent := []byte("the actual output content")
|
||||
outputHash, err := cache.StoreOutput(ctx, req, metaID, bytes.NewReader(outputContent), "image/webp")
|
||||
err := cache.StoreVariant(cacheKey, bytes.NewReader(outputContent), "image/webp")
|
||||
if err != nil {
|
||||
t.Fatalf("StoreOutput() error = %v", err)
|
||||
}
|
||||
if outputHash == "" {
|
||||
t.Fatal("StoreOutput() returned empty hash")
|
||||
t.Fatalf("StoreVariant() error = %v", err)
|
||||
}
|
||||
|
||||
// Lookup to get hash
|
||||
// Lookup to get cache key
|
||||
result, err := cache.Lookup(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup() error = %v", err)
|
||||
}
|
||||
|
||||
// Get output content
|
||||
reader, err := cache.GetOutput(result.OutputHash)
|
||||
// Get variant content
|
||||
reader, _, _, err := cache.GetVariant(result.CacheKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOutput() error = %v", err)
|
||||
t.Fatalf("GetVariant() error = %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
@@ -433,7 +395,7 @@ func TestCache_GetOutput(t *testing.T) {
|
||||
n, _ := reader.Read(buf)
|
||||
|
||||
if !bytes.Equal(buf[:n], outputContent) {
|
||||
t.Errorf("GetOutput() content = %q, want %q", buf[:n], outputContent)
|
||||
t.Errorf("GetVariant() content = %q, want %q", buf[:n], outputContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,9 +427,8 @@ func TestCache_CleanExpired(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
cache, _ := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
NegativeTTL: 1 * time.Millisecond,
|
||||
HotCacheEnabled: false,
|
||||
StateDir: tmpDir,
|
||||
NegativeTTL: 1 * time.Millisecond,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -506,8 +467,7 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
_, err := NewCache(db, CacheConfig{
|
||||
StateDir: tmpDir,
|
||||
HotCacheEnabled: false,
|
||||
StateDir: tmpDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCache() error = %v", err)
|
||||
@@ -515,9 +475,9 @@ func TestCache_StorageDirectoriesCreated(t *testing.T) {
|
||||
|
||||
// Verify directories were created
|
||||
dirs := []string{
|
||||
"cache/src-content",
|
||||
"cache/dst-content",
|
||||
"cache/src-metadata",
|
||||
"cache/sources",
|
||||
"cache/variants",
|
||||
"cache/metadata",
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
|
||||
Reference in New Issue
Block a user