A community based topic aggregation platform built on atproto
at main 141 lines 4.4 kB view raw
1// Package imageproxy provides image proxy functionality for AT Protocol applications. 2// It handles fetching, caching, and transforming images from Personal Data Servers (PDS). 3// 4// The package implements a multi-tier architecture: 5// - Service: Orchestrates caching, fetching, and processing 6// - Cache: Disk-based LRU cache with TTL-based expiration 7// - Fetcher: Retrieves blobs from AT Protocol PDSes 8// - Processor: Transforms images according to preset configurations 9// 10// Presets define image transformation parameters (dimensions, fit mode, quality) 11// for common use cases like avatars, banners, and feed thumbnails. 12package imageproxy 13 14import ( 15 "context" 16 "fmt" 17 "log/slog" 18 "sync/atomic" 19) 20 21// cacheWriteErrors tracks the number of async cache write failures. 22// This provides observability for cache write issues until proper metrics are implemented. 23var cacheWriteErrors atomic.Int64 24 25// CacheWriteErrorCount returns the total number of async cache write errors. 26// This is useful for monitoring and alerting on cache health. 27func CacheWriteErrorCount() int64 { 28 return cacheWriteErrors.Load() 29} 30 31// Service defines the interface for the image proxy service. 32type Service interface { 33 // GetImage retrieves an image for the given preset, DID, and CID. 34 // It checks the cache first, then fetches from the PDS if not cached, 35 // processes the image according to the preset, and stores in cache. 36 GetImage(ctx context.Context, preset, did, cid string, pdsURL string) ([]byte, error) 37} 38 39// ImageProxyService implements the Service interface and orchestrates 40// caching, fetching, and processing of images. 41type ImageProxyService struct { 42 cache Cache 43 processor Processor 44 fetcher Fetcher 45 config Config 46} 47 48// NewService creates a new ImageProxyService with the provided dependencies. 49// Returns an error if any required dependency is nil. 50func NewService(cache Cache, processor Processor, fetcher Fetcher, config Config) (*ImageProxyService, error) { 51 if cache == nil { 52 return nil, fmt.Errorf("%w: cache", ErrNilDependency) 53 } 54 if processor == nil { 55 return nil, fmt.Errorf("%w: processor", ErrNilDependency) 56 } 57 if fetcher == nil { 58 return nil, fmt.Errorf("%w: fetcher", ErrNilDependency) 59 } 60 61 return &ImageProxyService{ 62 cache: cache, 63 processor: processor, 64 fetcher: fetcher, 65 config: config, 66 }, nil 67} 68 69// GetImage retrieves an image for the given preset, DID, and CID. 70// The service flow is: 71// 1. Validate preset exists 72// 2. Check cache for (preset, did, cid) - return if hit 73// 3. Fetch blob from PDS using pdsURL 74// 4. Process image with preset 75// 5. Store in cache (async, don't block response) 76// 6. Return processed image 77func (s *ImageProxyService) GetImage(ctx context.Context, presetName, did, cid string, pdsURL string) ([]byte, error) { 78 // Step 1: Validate preset exists 79 preset, err := GetPreset(presetName) 80 if err != nil { 81 return nil, err 82 } 83 84 // Step 2: Check cache for (preset, did, cid) 85 cachedData, found, err := s.cache.Get(presetName, did, cid) 86 if err != nil { 87 // Log cache read error but continue - cache miss is acceptable 88 slog.Warn("[IMAGE-PROXY] cache read error, falling back to fetch", 89 "preset", presetName, 90 "did", did, 91 "cid", cid, 92 "error", err, 93 ) 94 } 95 if found { 96 slog.Debug("[IMAGE-PROXY] cache hit", 97 "preset", presetName, 98 "did", did, 99 "cid", cid, 100 ) 101 return cachedData, nil 102 } 103 104 // Step 3: Fetch blob from PDS 105 rawData, err := s.fetcher.Fetch(ctx, pdsURL, did, cid) 106 if err != nil { 107 return nil, err 108 } 109 110 // Step 4: Process image with preset 111 processedData, err := s.processor.Process(rawData, preset) 112 if err != nil { 113 return nil, err 114 } 115 116 // Step 5: Store in cache (async, don't block response) 117 go func() { 118 // Use a background context since the original request context may be cancelled 119 if cacheErr := s.cache.Set(presetName, did, cid, processedData); cacheErr != nil { 120 // Increment error counter for monitoring 121 cacheWriteErrors.Add(1) 122 slog.Error("[IMAGE-PROXY] async cache write failed", 123 "preset", presetName, 124 "did", did, 125 "cid", cid, 126 "error", cacheErr, 127 "total_cache_write_errors", cacheWriteErrors.Load(), 128 ) 129 } else { 130 slog.Debug("[IMAGE-PROXY] cached processed image", 131 "preset", presetName, 132 "did", did, 133 "cid", cid, 134 "size_bytes", len(processedData), 135 ) 136 } 137 }() 138 139 // Step 6: Return processed image 140 return processedData, nil 141}