-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcache.go
More file actions
288 lines (257 loc) · 6.91 KB
/
cache.go
File metadata and controls
288 lines (257 loc) · 6.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bytes"
"container/list"
"maps"
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// cacheEntry holds a cached response body, status code, headers, and expiry.
type cacheEntry struct {
status int
headers http.Header
body []byte
size int
expires time.Time
}
// cacheStore is a simple thread-safe in-memory cache keyed by request URL.
type cacheStore struct {
mu sync.RWMutex
entries map[string]*cacheEntry
order *list.List
index map[string]*list.Element
maxEntries int
maxBytes int
currentBytes int
}
// newCacheStore creates an empty cache store.
func newCacheStore(maxEntries, maxBytes int) *cacheStore {
return &cacheStore{
entries: make(map[string]*cacheEntry),
order: list.New(),
index: make(map[string]*list.Element),
maxEntries: maxEntries,
maxBytes: maxBytes,
}
}
// get retrieves a non-expired entry for the given key.
// Returns nil if the key is missing or expired.
func (s *cacheStore) get(key string) *cacheEntry {
s.mu.Lock()
entry, ok := s.entries[key]
if !ok {
s.mu.Unlock()
return nil
}
// Check expiry before promoting in the LRU order so we never move a stale
// entry to the front. All expiry checking and eviction happen inside the
// same critical section to avoid a TOCTOU race.
if time.Now().After(entry.expires) {
if elem, exists := s.index[key]; exists {
s.order.Remove(elem)
delete(s.index, key)
}
s.currentBytes -= entry.size
if s.currentBytes < 0 {
s.currentBytes = 0
}
delete(s.entries, key)
s.mu.Unlock()
return nil
}
// Only promote to LRU front after confirming the entry is still valid.
if elem, exists := s.index[key]; exists {
s.order.MoveToFront(elem)
}
s.mu.Unlock()
return entry
}
// set stores a cache entry with the given TTL.
func (s *cacheStore) set(key string, entry *cacheEntry) {
s.mu.Lock()
if entry.size <= 0 {
entry.size = cacheEntrySize(entry.headers, entry.body)
}
if elem, ok := s.index[key]; ok {
// Reject an oversized replacement before touching LRU state so the
// existing entry remains intact when the new value cannot fit.
if s.maxBytes > 0 && entry.size > s.maxBytes {
s.mu.Unlock()
return
}
if existing, exists := s.entries[key]; exists {
s.currentBytes -= existing.size
if s.currentBytes < 0 {
s.currentBytes = 0
}
}
s.order.MoveToFront(elem)
s.entries[key] = entry
s.currentBytes += entry.size
s.evictBySizeLocked()
s.mu.Unlock()
return
}
if s.maxBytes > 0 && entry.size > s.maxBytes {
s.mu.Unlock()
return
}
for (s.maxEntries > 0 && len(s.entries) >= s.maxEntries) || s.wouldExceedBytesLocked(entry.size) {
if !s.evictOldestLocked() {
break
}
}
if s.maxBytes > 0 && s.wouldExceedBytesLocked(entry.size) {
s.mu.Unlock()
return
}
s.entries[key] = entry
elem := s.order.PushFront(key)
s.index[key] = elem
s.currentBytes += entry.size
s.mu.Unlock()
}
func (s *cacheStore) wouldExceedBytesLocked(nextSize int) bool {
if s.maxBytes <= 0 {
return false
}
return s.currentBytes+nextSize > s.maxBytes
}
func (s *cacheStore) evictBySizeLocked() {
for s.maxBytes > 0 && s.currentBytes > s.maxBytes {
if !s.evictOldestLocked() {
return
}
}
}
func (s *cacheStore) evictOldestLocked() bool {
back := s.order.Back()
if back == nil {
return false
}
oldKey := back.Value.(string)
if existing, ok := s.entries[oldKey]; ok {
s.currentBytes -= existing.size
if s.currentBytes < 0 {
s.currentBytes = 0
}
}
delete(s.entries, oldKey)
delete(s.index, oldKey)
s.order.Remove(back)
return true
}
// cacheWriter intercepts writes to capture the response body and status.
type cacheWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *cacheWriter) Write(data []byte) (int, error) {
w.body.Write(data)
return w.ResponseWriter.Write(data)
}
func (w *cacheWriter) WriteString(s string) (int, error) {
w.body.WriteString(s)
return w.ResponseWriter.WriteString(s)
}
// cacheMiddleware returns Gin middleware that caches GET responses in memory.
// Only successful responses (2xx) are cached. Non-GET methods pass through.
func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
// Only cache GET requests.
if c.Request.Method != http.MethodGet {
c.Next()
return
}
key := c.Request.URL.RequestURI()
// Serve from cache if a valid entry exists.
if entry := store.get(key); entry != nil {
body := entry.body
metaRewritten := false
if meta := GetRequestMeta(c); meta != nil {
body = refreshCachedResponseMeta(entry.body, meta)
metaRewritten = true
}
// staleValidatorHeader returns true for headers that describe the
// exact bytes of the cached body and must be dropped when the body
// has been rewritten by refreshCachedResponseMeta.
staleValidatorHeader := func(canonical string) bool {
if !metaRewritten {
return false
}
switch canonical {
case "Etag", "Content-Md5", "Digest":
return true
}
return false
}
for k, vals := range entry.headers {
canonical := http.CanonicalHeaderKey(k)
if canonical == "X-Request-Id" {
continue
}
if canonical == "Content-Length" {
continue
}
if staleValidatorHeader(canonical) {
continue
}
for _, v := range vals {
c.Writer.Header().Add(k, v)
}
}
if requestID := GetRequestID(c); requestID != "" {
c.Writer.Header().Set("X-Request-ID", requestID)
} else if requestID := c.GetHeader("X-Request-ID"); requestID != "" {
c.Writer.Header().Set("X-Request-ID", requestID)
}
c.Writer.Header().Set("X-Cache", "HIT")
c.Writer.Header().Set("Content-Length", strconv.Itoa(len(body)))
c.Writer.WriteHeader(entry.status)
_, _ = c.Writer.Write(body)
c.Abort()
return
}
// Wrap the writer to capture the response.
cw := &cacheWriter{
ResponseWriter: c.Writer,
body: &bytes.Buffer{},
}
c.Writer = cw
c.Next()
// Only cache successful responses.
status := cw.ResponseWriter.Status()
if status >= 200 && status < 300 {
headers := make(http.Header)
maps.Copy(headers, cw.ResponseWriter.Header())
store.set(key, &cacheEntry{
status: status,
headers: headers,
body: cw.body.Bytes(),
size: cacheEntrySize(headers, cw.body.Bytes()),
expires: time.Now().Add(ttl),
})
}
}
}
// refreshCachedResponseMeta updates the meta envelope in a cached JSON body so
// request-scoped metadata reflects the current request instead of the cache fill.
// Non-JSON bodies, malformed JSON, and responses without a top-level object are
// returned unchanged.
func refreshCachedResponseMeta(body []byte, meta *Meta) []byte {
return refreshResponseMetaBody(body, meta)
}
func cacheEntrySize(headers http.Header, body []byte) int {
size := len(body)
for key, vals := range headers {
size += len(key)
for _, val := range vals {
size += len(val)
}
}
return size
}