chore: cache improvements fe69df3c
Steve Simkins · 2026-07-05 21:44 5 file(s) · +153 −38
cache.go +123 −15
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) {
go.mod +1 −0
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 (
go.sum +2 −0
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=
main.go +1 −1
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
	}
og.go +26 −22
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