Go · 4209 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package lru is an in-process least-recently-used cache with
4 // optional TTL + singleflight wrapping. The S36 perf-pass standardizes
5 // on this package for every cross-request cache (refs, tree, ahead/
6 // behind, rendered markdown, etc.) so callers don't roll their own
7 // eviction story.
8 //
9 // Two core types:
10 //
11 // - Cache[K,V] — count-bounded LRU with optional per-entry TTL.
12 // The dumb-and-fast variant for value types whose
13 // in-memory cost is uniform.
14 //
15 // - SizedCache[K] — byte-bounded LRU. Each entry contributes a
16 // caller-supplied size; eviction runs while the
17 // total exceeds the cap. For values whose size
18 // varies per key (rendered HTML, diff blobs).
19 //
20 // Both expose Get / Set / Delete / Len + a Stats accessor for the
21 // /metrics surface (S36 baseline asserts hit-rate). Hot-key dogpile
22 // prevention lives one layer up in `Group` (singleflight wrapper).
23 package lru
24
25 import (
26 "container/list"
27 "sync"
28 "sync/atomic"
29 "time"
30 )
31
32 // Stats is the per-cache hit / miss / eviction counter set. Counters
33 // are atomic so /metrics can read without locking the cache.
34 type Stats struct {
35 Hits uint64
36 Misses uint64
37 Evictions uint64
38 }
39
40 // Cache is a count-bounded LRU. Construct with New[K,V](capacity).
41 type Cache[K comparable, V any] struct {
42 mu sync.Mutex
43 capacity int
44 ll *list.List
45 items map[K]*list.Element
46 ttl time.Duration // zero = no TTL
47 now func() time.Time
48
49 hits atomic.Uint64
50 misses atomic.Uint64
51 evictions atomic.Uint64
52 }
53
54 // entry is the linked-list payload. ExpiresAt is zero when the
55 // cache has no TTL.
56 type entry[K comparable, V any] struct {
57 key K
58 val V
59 expiresAt time.Time
60 }
61
62 // New constructs a count-bounded LRU. capacity must be positive.
63 // The TTL defaults to "no expiry"; use NewWithTTL to set one.
64 func New[K comparable, V any](capacity int) *Cache[K, V] {
65 if capacity <= 0 {
66 panic("lru: capacity must be positive")
67 }
68 return &Cache[K, V]{
69 capacity: capacity,
70 ll: list.New(),
71 items: make(map[K]*list.Element, capacity),
72 now: time.Now,
73 }
74 }
75
76 // NewWithTTL is like New plus a per-entry TTL. Entries past their
77 // TTL are treated as misses on Get and dropped on access.
78 func NewWithTTL[K comparable, V any](capacity int, ttl time.Duration) *Cache[K, V] {
79 c := New[K, V](capacity)
80 c.ttl = ttl
81 return c
82 }
83
84 // Get returns the value for key + true on hit, zero value + false on
85 // miss (including TTL-expired entries).
86 func (c *Cache[K, V]) Get(key K) (V, bool) {
87 var zero V
88 c.mu.Lock()
89 defer c.mu.Unlock()
90 el, ok := c.items[key]
91 if !ok {
92 c.misses.Add(1)
93 return zero, false
94 }
95 e := el.Value.(*entry[K, V])
96 if c.ttl > 0 && c.now().After(e.expiresAt) {
97 c.removeElement(el)
98 c.misses.Add(1)
99 return zero, false
100 }
101 c.ll.MoveToFront(el)
102 c.hits.Add(1)
103 return e.val, true
104 }
105
106 // Set stores key→val, evicting the least-recently-used entry when at
107 // capacity. Replacing an existing key resets its TTL.
108 func (c *Cache[K, V]) Set(key K, val V) {
109 c.mu.Lock()
110 defer c.mu.Unlock()
111 if el, ok := c.items[key]; ok {
112 e := el.Value.(*entry[K, V])
113 e.val = val
114 if c.ttl > 0 {
115 e.expiresAt = c.now().Add(c.ttl)
116 }
117 c.ll.MoveToFront(el)
118 return
119 }
120 e := &entry[K, V]{key: key, val: val}
121 if c.ttl > 0 {
122 e.expiresAt = c.now().Add(c.ttl)
123 }
124 el := c.ll.PushFront(e)
125 c.items[key] = el
126 if c.ll.Len() > c.capacity {
127 c.removeElement(c.ll.Back())
128 c.evictions.Add(1)
129 }
130 }
131
132 // Delete removes key. No-op when absent.
133 func (c *Cache[K, V]) Delete(key K) {
134 c.mu.Lock()
135 defer c.mu.Unlock()
136 if el, ok := c.items[key]; ok {
137 c.removeElement(el)
138 }
139 }
140
141 // Len reports the live entry count (does NOT scan for TTL expiry —
142 // that happens lazily on Get).
143 func (c *Cache[K, V]) Len() int {
144 c.mu.Lock()
145 defer c.mu.Unlock()
146 return c.ll.Len()
147 }
148
149 // Stats returns a snapshot of the hit / miss / eviction counters.
150 func (c *Cache[K, V]) Stats() Stats {
151 return Stats{
152 Hits: c.hits.Load(),
153 Misses: c.misses.Load(),
154 Evictions: c.evictions.Load(),
155 }
156 }
157
158 func (c *Cache[K, V]) removeElement(el *list.Element) {
159 e := el.Value.(*entry[K, V])
160 c.ll.Remove(el)
161 delete(c.items, e.key)
162 }
163