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:
2026-01-08 16:55:20 -08:00
parent 555f150179
commit be293906bc
9 changed files with 476 additions and 381 deletions

View File

@@ -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)
}