chore: cache improvements
fe69df3c
5 file(s) · +153 −38
| 3 | 3 | import ( |
|
| 4 | 4 | "container/list" |
|
| 5 | 5 | "context" |
|
| 6 | + | "net/http" |
|
| 6 | 7 | "sync" |
|
| 7 | 8 | "time" |
|
| 9 | + | ||
| 10 | + | "golang.org/x/sync/singleflight" |
|
| 8 | 11 | ) |
|
| 9 | 12 | ||
| 10 | - | // feedCache is a small TTL cache of fetched feed results, keyed by feed URL. |
|
| 13 | + | // feedCache is an LRU + TTL cache of fetched feed results, keyed by feed URL. |
|
| 11 | 14 | // It keeps crawler-triggered requests (page + OG image) fast after the first |
|
| 12 | - | // fetch warms the entry. |
|
| 15 | + | // fetch warms the entry. A singleflight group collapses concurrent misses for |
|
| 16 | + | // the same URL into a single upstream fetch, and the bounded LRU keeps the |
|
| 17 | + | // entry set from growing without limit under a stream of distinct URLs. |
|
| 13 | 18 | type feedCache struct { |
|
| 14 | 19 | mu sync.Mutex |
|
| 15 | 20 | ttl time.Duration |
|
| 16 | - | entries map[string]feedCacheEntry |
|
| 21 | + | max int |
|
| 22 | + | ll *list.List // front = most recently used |
|
| 23 | + | entries map[string]*list.Element |
|
| 24 | + | sf singleflight.Group |
|
| 17 | 25 | } |
|
| 18 | 26 | ||
| 19 | 27 | type feedCacheEntry struct { |
|
| 28 | + | key string |
|
| 20 | 29 | res *FetchResult |
|
| 30 | + | etag string |
|
| 31 | + | lm string |
|
| 21 | 32 | expires time.Time |
|
| 22 | 33 | } |
|
| 23 | 34 | ||
| 24 | - | func newFeedCache(ttl time.Duration) *feedCache { |
|
| 25 | - | return &feedCache{ttl: ttl, entries: map[string]feedCacheEntry{}} |
|
| 35 | + | func newFeedCache(ttl time.Duration, max int) *feedCache { |
|
| 36 | + | return &feedCache{ |
|
| 37 | + | ttl: ttl, |
|
| 38 | + | max: max, |
|
| 39 | + | ll: list.New(), |
|
| 40 | + | entries: map[string]*list.Element{}, |
|
| 41 | + | } |
|
| 26 | 42 | } |
|
| 27 | 43 | ||
| 28 | 44 | // fetch returns a cached result when fresh, otherwise fetches and stores it. |
|
| 29 | - | // Concurrent misses for the same URL may each fetch; the last write wins. |
|
| 45 | + | // Concurrent misses for the same URL share one fetch via singleflight. |
|
| 30 | 46 | func (c *feedCache) fetch(ctx context.Context, feedURL string) (*FetchResult, error) { |
|
| 31 | - | c.mu.Lock() |
|
| 32 | - | if e, ok := c.entries[feedURL]; ok && time.Now().Before(e.expires) { |
|
| 33 | - | c.mu.Unlock() |
|
| 34 | - | return e.res, nil |
|
| 47 | + | if res, ok := c.lookup(feedURL); ok { |
|
| 48 | + | return res, nil |
|
| 35 | 49 | } |
|
| 36 | - | c.mu.Unlock() |
|
| 50 | + | ||
| 51 | + | v, err, _ := c.sf.Do(feedURL, func() (any, error) { |
|
| 52 | + | // Another goroutine may have filled the entry while we queued. |
|
| 53 | + | if res, ok := c.lookup(feedURL); ok { |
|
| 54 | + | return res, nil |
|
| 55 | + | } |
|
| 37 | 56 | ||
| 38 | - | res, err := fetchFeed(ctx, feedURL, "", "") |
|
| 57 | + | // Reuse any stored validators so an unchanged feed comes back as a |
|
| 58 | + | // cheap 304 instead of a full download + parse. |
|
| 59 | + | etag, lm, prev := c.validators(feedURL) |
|
| 60 | + | res, err := fetchFeed(ctx, feedURL, etag, lm) |
|
| 61 | + | if err != nil { |
|
| 62 | + | return nil, err |
|
| 63 | + | } |
|
| 64 | + | if res.Status == http.StatusNotModified && prev != nil { |
|
| 65 | + | res = prev |
|
| 66 | + | } |
|
| 67 | + | c.store(feedURL, res) |
|
| 68 | + | return res, nil |
|
| 69 | + | }) |
|
| 39 | 70 | if err != nil { |
|
| 40 | 71 | return nil, err |
|
| 41 | 72 | } |
|
| 73 | + | return v.(*FetchResult), nil |
|
| 74 | + | } |
|
| 42 | 75 | ||
| 76 | + | // lookup returns a fresh (unexpired) cached result and marks it recently used. |
|
| 77 | + | func (c *feedCache) lookup(feedURL string) (*FetchResult, bool) { |
|
| 43 | 78 | c.mu.Lock() |
|
| 44 | - | c.entries[feedURL] = feedCacheEntry{res: res, expires: time.Now().Add(c.ttl)} |
|
| 45 | - | c.mu.Unlock() |
|
| 46 | - | return res, nil |
|
| 79 | + | defer c.mu.Unlock() |
|
| 80 | + | el, ok := c.entries[feedURL] |
|
| 81 | + | if !ok { |
|
| 82 | + | return nil, false |
|
| 83 | + | } |
|
| 84 | + | e := el.Value.(*feedCacheEntry) |
|
| 85 | + | if time.Now().After(e.expires) { |
|
| 86 | + | return nil, false |
|
| 87 | + | } |
|
| 88 | + | c.ll.MoveToFront(el) |
|
| 89 | + | return e.res, true |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | // validators returns stored conditional-GET headers and the last parsed result |
|
| 93 | + | // for a URL, even when the entry has expired, so a refetch can revalidate. |
|
| 94 | + | func (c *feedCache) validators(feedURL string) (etag, lm string, prev *FetchResult) { |
|
| 95 | + | c.mu.Lock() |
|
| 96 | + | defer c.mu.Unlock() |
|
| 97 | + | if el, ok := c.entries[feedURL]; ok { |
|
| 98 | + | e := el.Value.(*feedCacheEntry) |
|
| 99 | + | return e.etag, e.lm, e.res |
|
| 100 | + | } |
|
| 101 | + | return "", "", nil |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | func (c *feedCache) store(feedURL string, res *FetchResult) { |
|
| 105 | + | c.mu.Lock() |
|
| 106 | + | defer c.mu.Unlock() |
|
| 107 | + | expires := time.Now().Add(c.ttl) |
|
| 108 | + | if el, ok := c.entries[feedURL]; ok { |
|
| 109 | + | e := el.Value.(*feedCacheEntry) |
|
| 110 | + | e.res, e.etag, e.lm, e.expires = res, res.ETag, res.LastModified, expires |
|
| 111 | + | c.ll.MoveToFront(el) |
|
| 112 | + | return |
|
| 113 | + | } |
|
| 114 | + | el := c.ll.PushFront(&feedCacheEntry{ |
|
| 115 | + | key: feedURL, |
|
| 116 | + | res: res, |
|
| 117 | + | etag: res.ETag, |
|
| 118 | + | lm: res.LastModified, |
|
| 119 | + | expires: expires, |
|
| 120 | + | }) |
|
| 121 | + | c.entries[feedURL] = el |
|
| 122 | + | for c.ll.Len() > c.max { |
|
| 123 | + | oldest := c.ll.Back() |
|
| 124 | + | if oldest == nil { |
|
| 125 | + | break |
|
| 126 | + | } |
|
| 127 | + | c.ll.Remove(oldest) |
|
| 128 | + | delete(c.entries, oldest.Value.(*feedCacheEntry).key) |
|
| 129 | + | } |
|
| 47 | 130 | } |
|
| 48 | 131 | ||
| 49 | 132 | // imageCache is an LRU + TTL cache of rendered PNG bytes, keyed by the shared |
|
| 55 | 138 | max int |
|
| 56 | 139 | ll *list.List // front = most recently used |
|
| 57 | 140 | entries map[string]*list.Element |
|
| 141 | + | sf singleflight.Group |
|
| 58 | 142 | } |
|
| 59 | 143 | ||
| 60 | 144 | type imageEntry struct { |
|
| 87 | 171 | } |
|
| 88 | 172 | c.ll.MoveToFront(el) |
|
| 89 | 173 | return ent.png, true |
|
| 174 | + | } |
|
| 175 | + | ||
| 176 | + | // getOrRender returns a cached PNG or renders one via render, collapsing |
|
| 177 | + | // concurrent requests for the same key into a single render so duplicate |
|
| 178 | + | // crawler hits don't each pay the CPU/allocation cost. |
|
| 179 | + | func (c *imageCache) getOrRender(key string, render func() ([]byte, error)) ([]byte, error) { |
|
| 180 | + | if png, ok := c.get(key); ok { |
|
| 181 | + | return png, nil |
|
| 182 | + | } |
|
| 183 | + | v, err, _ := c.sf.Do(key, func() (any, error) { |
|
| 184 | + | if png, ok := c.get(key); ok { |
|
| 185 | + | return png, nil |
|
| 186 | + | } |
|
| 187 | + | png, err := render() |
|
| 188 | + | if err != nil { |
|
| 189 | + | return nil, err |
|
| 190 | + | } |
|
| 191 | + | c.set(key, png) |
|
| 192 | + | return png, nil |
|
| 193 | + | }) |
|
| 194 | + | if err != nil { |
|
| 195 | + | return nil, err |
|
| 196 | + | } |
|
| 197 | + | return v.([]byte), nil |
|
| 90 | 198 | } |
|
| 91 | 199 | ||
| 92 | 200 | func (c *imageCache) set(key string, png []byte) { |
|
| 8 | 8 | golang.org/x/crypto/x509roots/fallback v0.0.0-20260511143831-44decbfe70e2 |
|
| 9 | 9 | golang.org/x/image v0.43.0 |
|
| 10 | 10 | golang.org/x/net v0.41.0 |
|
| 11 | + | golang.org/x/sync v0.21.0 |
|
| 11 | 12 | ) |
|
| 12 | 13 | ||
| 13 | 14 | require ( |
| 34 | 34 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= |
|
| 35 | 35 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= |
|
| 36 | 36 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= |
|
| 37 | + | golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
|
| 38 | + | golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
|
| 37 | 39 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 38 | 40 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 39 | 41 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
| 23 | 23 | Log: logger, |
|
| 24 | 24 | Templates: tmpl, |
|
| 25 | 25 | BaseURL: getenv("BASE_URL", "http://localhost:3000"), |
|
| 26 | - | Cache: newFeedCache(5 * time.Minute), |
|
| 26 | + | Cache: newFeedCache(5*time.Minute, 512), |
|
| 27 | 27 | Images: newImageCache(10*time.Minute, 512), |
|
| 28 | 28 | renderSem: make(chan struct{}, renderSlots), |
|
| 29 | 29 | } |
| 3 | 3 | import ( |
|
| 4 | 4 | "bytes" |
|
| 5 | 5 | "context" |
|
| 6 | + | "errors" |
|
| 6 | 7 | "net/http" |
|
| 7 | 8 | "sort" |
|
| 8 | 9 | "strings" |
|
| 14 | 15 | "golang.org/x/image/font" |
|
| 15 | 16 | "golang.org/x/image/font/opentype" |
|
| 16 | 17 | ) |
|
| 18 | + | ||
| 19 | + | // errRenderBusy signals that the render semaphore was saturated and the request |
|
| 20 | + | // gave up rather than piling on. Mapped to HTTP 503 by the OG handler. |
|
| 21 | + | var errRenderBusy = errors.New("render busy") |
|
| 17 | 22 | ||
| 18 | 23 | const ( |
|
| 19 | 24 | ogWidth = 1200 |
|
| 71 | 76 | urls := feedURLsFromRequest(r) |
|
| 72 | 77 | key := ogCacheKey(urls) |
|
| 73 | 78 | ||
| 74 | - | // Serve a previously rendered PNG without touching the render path. |
|
| 75 | - | if png, ok := a.Images.get(key); ok { |
|
| 76 | - | writePNG(w, png) |
|
| 77 | - | return |
|
| 78 | - | } |
|
| 79 | - | ||
| 80 | 79 | ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) |
|
| 81 | 80 | defer cancel() |
|
| 82 | 81 | ||
| 83 | - | title, desc := "Feeds", "Experience RSS feeds" |
|
| 84 | - | if len(urls) > 0 { |
|
| 85 | - | items, titles := previewURLs(ctx, urls, 0, a.Cache, a.Log) |
|
| 86 | - | title, desc = feedMeta(urls, titles, len(items)) |
|
| 87 | - | } |
|
| 82 | + | // getOrRender serves a cached PNG or, on a miss, runs the render once even |
|
| 83 | + | // under concurrent requests for the same key. |
|
| 84 | + | png, err := a.Images.getOrRender(key, func() ([]byte, error) { |
|
| 85 | + | title, desc := "Feeds", "Experience RSS feeds" |
|
| 86 | + | if len(urls) > 0 { |
|
| 87 | + | items, titles := previewURLs(ctx, urls, 0, a.Cache, a.Log) |
|
| 88 | + | title, desc = feedMeta(urls, titles, len(items)) |
|
| 89 | + | } |
|
| 88 | 90 | ||
| 89 | - | // Bound concurrent renders to cap CPU and peak memory (each render |
|
| 90 | - | // allocates a ~3MB bitmap). Overloaded requests bail rather than pile up. |
|
| 91 | - | select { |
|
| 92 | - | case a.renderSem <- struct{}{}: |
|
| 93 | - | defer func() { <-a.renderSem }() |
|
| 94 | - | case <-ctx.Done(): |
|
| 91 | + | // Bound concurrent renders to cap CPU and peak memory (each render |
|
| 92 | + | // allocates a ~3MB bitmap). Overloaded requests bail rather than pile up. |
|
| 93 | + | select { |
|
| 94 | + | case a.renderSem <- struct{}{}: |
|
| 95 | + | defer func() { <-a.renderSem }() |
|
| 96 | + | case <-ctx.Done(): |
|
| 97 | + | return nil, errRenderBusy |
|
| 98 | + | } |
|
| 99 | + | return renderOGImage(title, desc) |
|
| 100 | + | }) |
|
| 101 | + | switch { |
|
| 102 | + | case err == errRenderBusy: |
|
| 95 | 103 | http.Error(w, "render busy", http.StatusServiceUnavailable) |
|
| 96 | 104 | return |
|
| 97 | - | } |
|
| 98 | - | ||
| 99 | - | png, err := renderOGImage(title, desc) |
|
| 100 | - | if err != nil { |
|
| 105 | + | case err != nil: |
|
| 101 | 106 | a.Log.Error("og render failed", "err", err) |
|
| 102 | 107 | http.Error(w, "og render error", http.StatusInternalServerError) |
|
| 103 | 108 | return |
|
| 104 | 109 | } |
|
| 105 | - | a.Images.set(key, png) |
|
| 106 | 110 | writePNG(w, png) |
|
| 107 | 111 | } |
|
| 108 | 112 | ||