cache.go 5.4 K raw
1
package main
2
3
import (
4
	"container/list"
5
	"context"
6
	"net/http"
7
	"sync"
8
	"time"
9
10
	"golang.org/x/sync/singleflight"
11
)
12
13
// feedCache is an LRU + TTL cache of fetched feed results, keyed by feed URL.
14
// It keeps crawler-triggered requests (page + OG image) fast after the first
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.
18
type feedCache struct {
19
	mu      sync.Mutex
20
	ttl     time.Duration
21
	max     int
22
	ll      *list.List // front = most recently used
23
	entries map[string]*list.Element
24
	sf      singleflight.Group
25
}
26
27
type feedCacheEntry struct {
28
	key     string
29
	res     *FetchResult
30
	etag    string
31
	lm      string
32
	expires time.Time
33
}
34
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
	}
42
}
43
44
// fetch returns a cached result when fresh, otherwise fetches and stores it.
45
// Concurrent misses for the same URL share one fetch via singleflight.
46
func (c *feedCache) fetch(ctx context.Context, feedURL string) (*FetchResult, error) {
47
	if res, ok := c.lookup(feedURL); ok {
48
		return res, nil
49
	}
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
		}
56
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
	})
70
	if err != nil {
71
		return nil, err
72
	}
73
	return v.(*FetchResult), nil
74
}
75
76
// lookup returns a fresh (unexpired) cached result and marks it recently used.
77
func (c *feedCache) lookup(feedURL string) (*FetchResult, bool) {
78
	c.mu.Lock()
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
	}
130
}
131
132
// imageCache is an LRU + TTL cache of rendered PNG bytes, keyed by the shared
133
// URL set. It bounds memory (max entries) and lets repeated shares of the same
134
// link skip the CPU/allocation cost of re-rendering.
135
type imageCache struct {
136
	mu      sync.Mutex
137
	ttl     time.Duration
138
	max     int
139
	ll      *list.List // front = most recently used
140
	entries map[string]*list.Element
141
	sf      singleflight.Group
142
}
143
144
type imageEntry struct {
145
	key     string
146
	png     []byte
147
	expires time.Time
148
}
149
150
func newImageCache(ttl time.Duration, max int) *imageCache {
151
	return &imageCache{
152
		ttl:     ttl,
153
		max:     max,
154
		ll:      list.New(),
155
		entries: map[string]*list.Element{},
156
	}
157
}
158
159
func (c *imageCache) get(key string) ([]byte, bool) {
160
	c.mu.Lock()
161
	defer c.mu.Unlock()
162
	el, ok := c.entries[key]
163
	if !ok {
164
		return nil, false
165
	}
166
	ent := el.Value.(*imageEntry)
167
	if time.Now().After(ent.expires) {
168
		c.ll.Remove(el)
169
		delete(c.entries, key)
170
		return nil, false
171
	}
172
	c.ll.MoveToFront(el)
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
198
}
199
200
func (c *imageCache) set(key string, png []byte) {
201
	c.mu.Lock()
202
	defer c.mu.Unlock()
203
	if el, ok := c.entries[key]; ok {
204
		ent := el.Value.(*imageEntry)
205
		ent.png = png
206
		ent.expires = time.Now().Add(c.ttl)
207
		c.ll.MoveToFront(el)
208
		return
209
	}
210
	el := c.ll.PushFront(&imageEntry{key: key, png: png, expires: time.Now().Add(c.ttl)})
211
	c.entries[key] = el
212
	for c.ll.Len() > c.max {
213
		oldest := c.ll.Back()
214
		if oldest == nil {
215
			break
216
		}
217
		c.ll.Remove(oldest)
218
		delete(c.entries, oldest.Value.(*imageEntry).key)
219
	}
220
}