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:
@@ -23,170 +23,89 @@ const httpStatusOK = 200
|
||||
|
||||
// CacheConfig holds cache configuration.
|
||||
type CacheConfig struct {
|
||||
StateDir string
|
||||
CacheTTL time.Duration
|
||||
NegativeTTL time.Duration
|
||||
HotCacheSize int
|
||||
HotCacheEnabled bool
|
||||
StateDir string
|
||||
CacheTTL time.Duration
|
||||
NegativeTTL time.Duration
|
||||
}
|
||||
|
||||
// hotCacheEntry stores all data needed to serve a cache hit without DB access.
|
||||
type hotCacheEntry struct {
|
||||
OutputHash string
|
||||
// variantMeta stores content type for fast cache hits without reading .meta file.
|
||||
type variantMeta struct {
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Cache implements the caching layer for the image proxy.
|
||||
type Cache struct {
|
||||
db *sql.DB
|
||||
srcContent *ContentStorage
|
||||
dstContent *ContentStorage
|
||||
srcMetadata *MetadataStorage
|
||||
config CacheConfig
|
||||
hotCache map[string]hotCacheEntry // cache_key -> entry
|
||||
hotCacheMu sync.RWMutex
|
||||
hotCacheEnabled bool
|
||||
db *sql.DB
|
||||
srcContent *ContentStorage // source images by content hash
|
||||
variants *VariantStorage // processed variants by cache key
|
||||
srcMetadata *MetadataStorage // source metadata by host/path
|
||||
config CacheConfig
|
||||
|
||||
// In-memory cache of variant metadata (content type, size) to avoid reading .meta files
|
||||
metaCache map[VariantKey]variantMeta
|
||||
metaCacheMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCache creates a new cache instance.
|
||||
func NewCache(db *sql.DB, config CacheConfig) (*Cache, error) {
|
||||
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "src-content"))
|
||||
srcContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "sources"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create source content storage: %w", err)
|
||||
}
|
||||
|
||||
dstContent, err := NewContentStorage(filepath.Join(config.StateDir, "cache", "dst-content"))
|
||||
variants, err := NewVariantStorage(filepath.Join(config.StateDir, "cache", "variants"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create destination content storage: %w", err)
|
||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||
}
|
||||
|
||||
srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "src-metadata"))
|
||||
srcMetadata, err := NewMetadataStorage(filepath.Join(config.StateDir, "cache", "metadata"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create source metadata storage: %w", err)
|
||||
}
|
||||
|
||||
c := &Cache{
|
||||
db: db,
|
||||
srcContent: srcContent,
|
||||
dstContent: dstContent,
|
||||
srcMetadata: srcMetadata,
|
||||
config: config,
|
||||
hotCacheEnabled: config.HotCacheEnabled,
|
||||
}
|
||||
|
||||
if config.HotCacheEnabled && config.HotCacheSize > 0 {
|
||||
c.hotCache = make(map[string]hotCacheEntry, config.HotCacheSize)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
return &Cache{
|
||||
db: db,
|
||||
srcContent: srcContent,
|
||||
variants: variants,
|
||||
srcMetadata: srcMetadata,
|
||||
config: config,
|
||||
metaCache: make(map[VariantKey]variantMeta),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LookupResult contains the result of a cache lookup.
|
||||
type LookupResult struct {
|
||||
Hit bool
|
||||
OutputHash string
|
||||
CacheKey VariantKey
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
CacheStatus CacheStatus
|
||||
}
|
||||
|
||||
// Lookup checks if a processed image exists in the cache.
|
||||
func (c *Cache) Lookup(ctx context.Context, req *ImageRequest) (*LookupResult, error) {
|
||||
// Lookup checks if a processed variant exists on disk (no DB access for hits).
|
||||
func (c *Cache) Lookup(_ context.Context, req *ImageRequest) (*LookupResult, error) {
|
||||
cacheKey := CacheKey(req)
|
||||
|
||||
// Check hot cache first
|
||||
if c.hotCacheEnabled {
|
||||
c.hotCacheMu.RLock()
|
||||
entry, ok := c.hotCache[cacheKey]
|
||||
c.hotCacheMu.RUnlock()
|
||||
|
||||
if ok && c.dstContent.Exists(entry.OutputHash) {
|
||||
return &LookupResult{
|
||||
Hit: true,
|
||||
OutputHash: entry.OutputHash,
|
||||
ContentType: entry.ContentType,
|
||||
SizeBytes: entry.SizeBytes,
|
||||
CacheStatus: CacheHit,
|
||||
}, nil
|
||||
}
|
||||
// Check variant storage directly - no DB needed for cache hits
|
||||
if c.variants.Exists(cacheKey) {
|
||||
return &LookupResult{
|
||||
Hit: true,
|
||||
CacheKey: cacheKey,
|
||||
CacheStatus: CacheHit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check negative cache
|
||||
negCached, err := c.checkNegativeCache(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if negCached {
|
||||
return nil, ErrNegativeCache
|
||||
}
|
||||
|
||||
// Check database
|
||||
var outputHash, contentType string
|
||||
var sizeBytes int64
|
||||
var fetchedAt time.Time
|
||||
|
||||
err = c.db.QueryRowContext(ctx, `
|
||||
SELECT rc.output_hash, oc.content_type, oc.size_bytes, rc.fetched_at
|
||||
FROM request_cache rc
|
||||
JOIN output_content oc ON rc.output_hash = oc.content_hash
|
||||
WHERE rc.cache_key = ?
|
||||
`, cacheKey).Scan(&outputHash, &contentType, &sizeBytes, &fetchedAt)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return &LookupResult{Hit: false, CacheStatus: CacheMiss}, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query cache: %w", err)
|
||||
}
|
||||
|
||||
// Check TTL
|
||||
if c.config.CacheTTL > 0 && time.Since(fetchedAt) > c.config.CacheTTL {
|
||||
return &LookupResult{Hit: false, CacheStatus: CacheStale}, nil
|
||||
}
|
||||
|
||||
// Verify file exists on disk
|
||||
if !c.dstContent.Exists(outputHash) {
|
||||
return &LookupResult{Hit: false, CacheStatus: CacheMiss}, nil
|
||||
}
|
||||
|
||||
// Update hot cache
|
||||
if c.hotCacheEnabled {
|
||||
c.hotCacheMu.Lock()
|
||||
c.hotCache[cacheKey] = hotCacheEntry{
|
||||
OutputHash: outputHash,
|
||||
ContentType: contentType,
|
||||
SizeBytes: sizeBytes,
|
||||
}
|
||||
c.hotCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// Update access count
|
||||
_, _ = c.db.ExecContext(ctx, `
|
||||
UPDATE request_cache
|
||||
SET access_count = access_count + 1
|
||||
WHERE cache_key = ?
|
||||
`, cacheKey)
|
||||
|
||||
return &LookupResult{
|
||||
Hit: true,
|
||||
OutputHash: outputHash,
|
||||
ContentType: contentType,
|
||||
SizeBytes: sizeBytes,
|
||||
CacheStatus: CacheHit,
|
||||
Hit: false,
|
||||
CacheKey: cacheKey,
|
||||
CacheStatus: CacheMiss,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetOutput returns a reader for cached output content.
|
||||
func (c *Cache) GetOutput(outputHash string) (io.ReadCloser, error) {
|
||||
return c.dstContent.Load(outputHash)
|
||||
}
|
||||
|
||||
// GetOutputWithSize returns a reader and size for cached output content.
|
||||
func (c *Cache) GetOutputWithSize(outputHash string) (io.ReadCloser, int64, error) {
|
||||
return c.dstContent.LoadWithSize(outputHash)
|
||||
// GetVariant returns a reader, size, and content type for a cached variant.
|
||||
func (c *Cache) GetVariant(cacheKey VariantKey) (io.ReadCloser, int64, string, error) {
|
||||
return c.variants.LoadWithMeta(cacheKey)
|
||||
}
|
||||
|
||||
// StoreSource stores fetched source content and metadata.
|
||||
@@ -195,7 +114,7 @@ func (c *Cache) StoreSource(
|
||||
req *ImageRequest,
|
||||
content io.Reader,
|
||||
result *FetchResult,
|
||||
) (contentHash string, err error) {
|
||||
) (ContentHash, error) {
|
||||
// Store content
|
||||
contentHash, size, err := c.srcContent.Store(content)
|
||||
if err != nil {
|
||||
@@ -237,12 +156,12 @@ func (c *Cache) StoreSource(
|
||||
Host: req.SourceHost,
|
||||
Path: req.SourcePath,
|
||||
Query: req.SourceQuery,
|
||||
ContentHash: contentHash,
|
||||
ContentHash: string(contentHash),
|
||||
StatusCode: result.StatusCode,
|
||||
ContentType: result.ContentType,
|
||||
ContentLength: result.ContentLength,
|
||||
ResponseHeaders: result.Headers,
|
||||
FetchedAt: time.Now().Unix(),
|
||||
FetchedAt: time.Now().UTC().Unix(),
|
||||
FetchDurationMs: result.FetchDurationMs,
|
||||
RemoteAddr: result.RemoteAddr,
|
||||
}
|
||||
@@ -255,61 +174,43 @@ func (c *Cache) StoreSource(
|
||||
return contentHash, nil
|
||||
}
|
||||
|
||||
// StoreOutput stores processed output content and returns the output hash.
|
||||
func (c *Cache) StoreOutput(
|
||||
ctx context.Context,
|
||||
req *ImageRequest,
|
||||
sourceMetadataID int64,
|
||||
content io.Reader,
|
||||
contentType string,
|
||||
) (string, error) {
|
||||
// Store content
|
||||
outputHash, size, err := c.dstContent.Store(content)
|
||||
// StoreVariant stores a processed variant by its cache key.
|
||||
func (c *Cache) StoreVariant(cacheKey VariantKey, content io.Reader, contentType string) error {
|
||||
_, err := c.variants.Store(cacheKey, content, contentType)
|
||||
return err
|
||||
}
|
||||
|
||||
// LookupSource checks if we have cached source content for a request.
|
||||
// Returns the content hash and content type if found, or empty values if not.
|
||||
func (c *Cache) LookupSource(ctx context.Context, req *ImageRequest) (ContentHash, string, error) {
|
||||
var hashStr, contentType string
|
||||
|
||||
err := c.db.QueryRowContext(ctx, `
|
||||
SELECT content_hash, content_type FROM source_metadata
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, req.SourceHost, req.SourcePath, req.SourceQuery).Scan(&hashStr, &contentType)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store output content: %w", err)
|
||||
return "", "", fmt.Errorf("failed to lookup source: %w", err)
|
||||
}
|
||||
|
||||
cacheKey := CacheKey(req)
|
||||
contentHash := ContentHash(hashStr)
|
||||
|
||||
// Store in database
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO output_content (content_hash, content_type, size_bytes)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(content_hash) DO NOTHING
|
||||
`, outputHash, contentType, size)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to insert output content: %w", err)
|
||||
// Verify the content file exists
|
||||
if !c.srcContent.Exists(contentHash) {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO request_cache (cache_key, source_metadata_id, output_hash, width, height, format, quality, fit_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
output_hash = excluded.output_hash,
|
||||
fetched_at = CURRENT_TIMESTAMP,
|
||||
access_count = request_cache.access_count + 1
|
||||
`, cacheKey, sourceMetadataID, outputHash, req.Size.Width, req.Size.Height, req.Format, req.Quality, req.FitMode)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to insert request cache: %w", err)
|
||||
}
|
||||
|
||||
// Update hot cache
|
||||
if c.hotCacheEnabled {
|
||||
c.hotCacheMu.Lock()
|
||||
c.hotCache[cacheKey] = hotCacheEntry{
|
||||
OutputHash: outputHash,
|
||||
ContentType: contentType,
|
||||
SizeBytes: size,
|
||||
}
|
||||
c.hotCacheMu.Unlock()
|
||||
}
|
||||
|
||||
return outputHash, nil
|
||||
return contentHash, contentType, nil
|
||||
}
|
||||
|
||||
// StoreNegative stores a negative cache entry for a failed fetch.
|
||||
func (c *Cache) StoreNegative(ctx context.Context, req *ImageRequest, statusCode int, errMsg string) error {
|
||||
expiresAt := time.Now().Add(c.config.NegativeTTL)
|
||||
expiresAt := time.Now().UTC().Add(c.config.NegativeTTL)
|
||||
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
INSERT INTO negative_cache (source_host, source_path, source_query, status_code, error_message, expires_at)
|
||||
@@ -375,7 +276,7 @@ func (c *Cache) GetSourceMetadataID(ctx context.Context, req *ImageRequest) (int
|
||||
}
|
||||
|
||||
// GetSourceContent returns a reader for cached source content by its hash.
|
||||
func (c *Cache) GetSourceContent(contentHash string) (io.ReadCloser, error) {
|
||||
func (c *Cache) GetSourceContent(contentHash ContentHash) (io.ReadCloser, error) {
|
||||
return c.srcContent.Load(contentHash)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user