init
This commit is contained in:
Vendored
+179
@@ -0,0 +1,179 @@
|
||||
// Package cache provides a bounded, TTL-expiring LRU map.
|
||||
//
|
||||
// It exists for the one place that genuinely needs eviction: the failed-auth
|
||||
// rate limiter, whose key space is attacker-controlled and must not be allowed
|
||||
// to grow without bound. The other caches in this server deliberately do not
|
||||
// use it — the project registry and deployment manifests are copy-on-write
|
||||
// snapshots read without any lock at all, and adding an LRU there would put a
|
||||
// contended mutex on the hot serving path to solve a problem that does not
|
||||
// exist.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type node[K comparable, V any] struct {
|
||||
key K
|
||||
val V
|
||||
expires time.Time
|
||||
prev, next *node[K, V]
|
||||
}
|
||||
|
||||
// Cache maps K to V with a size cap and a per-entry TTL. It is safe for
|
||||
// concurrent use. Entries are evicted when the cap is exceeded (least recently
|
||||
// used first) or when their TTL passes, whichever comes first.
|
||||
//
|
||||
// The zero value is not usable; call New.
|
||||
type Cache[K comparable, V any] struct {
|
||||
mu sync.Mutex
|
||||
m map[K]*node[K, V]
|
||||
head *node[K, V] // most recently used
|
||||
tail *node[K, V] // least recently used
|
||||
max int
|
||||
ttl time.Duration
|
||||
|
||||
// now is swapped out by tests. Production always uses time.Now.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New returns a cache holding at most max entries for at most ttl each.
|
||||
func New[K comparable, V any](max int, ttl time.Duration) *Cache[K, V] {
|
||||
if max < 1 {
|
||||
max = 1
|
||||
}
|
||||
return &Cache[K, V]{
|
||||
m: make(map[K]*node[K, V]),
|
||||
max: max,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the value for k, refreshing its recency. A value whose TTL has
|
||||
// passed is reported as absent and dropped.
|
||||
func (c *Cache[K, V]) Get(k K) (V, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
n, ok := c.m[k]
|
||||
if !ok {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
if !c.now().Before(n.expires) {
|
||||
c.remove(n)
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.moveToFront(n)
|
||||
return n.val, true
|
||||
}
|
||||
|
||||
// Put inserts or replaces the value for k and resets its TTL.
|
||||
func (c *Cache[K, V]) Put(k K, v V) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.put(k, v)
|
||||
}
|
||||
|
||||
// GetOrCreate returns the existing value for k, or stores and returns the one
|
||||
// newVal produces.
|
||||
//
|
||||
// The whole operation happens under the lock, which is what makes it usable for
|
||||
// the rate limiter: two concurrent requests from the same address must share
|
||||
// one token bucket, and a Get-then-Put pair would hand each of them its own.
|
||||
// newVal must not call back into the cache.
|
||||
func (c *Cache[K, V]) GetOrCreate(k K, newVal func() V) V {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if n, ok := c.m[k]; ok {
|
||||
if c.now().Before(n.expires) {
|
||||
c.moveToFront(n)
|
||||
return n.val
|
||||
}
|
||||
c.remove(n)
|
||||
}
|
||||
v := newVal()
|
||||
c.put(k, v)
|
||||
return v
|
||||
}
|
||||
|
||||
// Delete drops k if present.
|
||||
func (c *Cache[K, V]) Delete(k K) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if n, ok := c.m[k]; ok {
|
||||
c.remove(n)
|
||||
}
|
||||
}
|
||||
|
||||
// Len reports the number of entries, including any whose TTL has passed but
|
||||
// that have not been touched since. It is meant for tests and diagnostics.
|
||||
func (c *Cache[K, V]) Len() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.m)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- internals
|
||||
// All of these require c.mu.
|
||||
|
||||
func (c *Cache[K, V]) put(k K, v V) {
|
||||
if n, ok := c.m[k]; ok {
|
||||
n.val = v
|
||||
n.expires = c.now().Add(c.ttl)
|
||||
c.moveToFront(n)
|
||||
return
|
||||
}
|
||||
n := &node[K, V]{key: k, val: v, expires: c.now().Add(c.ttl)}
|
||||
c.m[k] = n
|
||||
c.pushFront(n)
|
||||
for len(c.m) > c.max {
|
||||
c.remove(c.tail)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) pushFront(n *node[K, V]) {
|
||||
n.prev = nil
|
||||
n.next = c.head
|
||||
if c.head != nil {
|
||||
c.head.prev = n
|
||||
}
|
||||
c.head = n
|
||||
if c.tail == nil {
|
||||
c.tail = n
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) moveToFront(n *node[K, V]) {
|
||||
if c.head == n {
|
||||
return
|
||||
}
|
||||
c.unlink(n)
|
||||
c.pushFront(n)
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) remove(n *node[K, V]) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
c.unlink(n)
|
||||
delete(c.m, n.key)
|
||||
}
|
||||
|
||||
func (c *Cache[K, V]) unlink(n *node[K, V]) {
|
||||
if n.prev != nil {
|
||||
n.prev.next = n.next
|
||||
} else if c.head == n {
|
||||
c.head = n.next
|
||||
}
|
||||
if n.next != nil {
|
||||
n.next.prev = n.prev
|
||||
} else if c.tail == n {
|
||||
c.tail = n.prev
|
||||
}
|
||||
n.prev, n.next = nil, nil
|
||||
}
|
||||
Vendored
+223
@@ -0,0 +1,223 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clock lets the TTL tests run without sleeping.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *clock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *clock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = c.t.Add(d)
|
||||
}
|
||||
|
||||
func newTestCache[K comparable, V any](t *testing.T, max int, ttl time.Duration) (*Cache[K, V], *clock) {
|
||||
t.Helper()
|
||||
c := New[K, V](max, ttl)
|
||||
clk := &clock{t: time.Unix(1_700_000_000, 0)}
|
||||
c.now = clk.now
|
||||
return c, clk
|
||||
}
|
||||
|
||||
func TestGetPut(t *testing.T) {
|
||||
c, _ := newTestCache[string, int](t, 4, time.Minute)
|
||||
if _, ok := c.Get("missing"); ok {
|
||||
t.Error("empty cache returned a hit")
|
||||
}
|
||||
c.Put("a", 1)
|
||||
if v, ok := c.Get("a"); !ok || v != 1 {
|
||||
t.Errorf("Get(a) = %v, %v", v, ok)
|
||||
}
|
||||
c.Put("a", 2)
|
||||
if v, _ := c.Get("a"); v != 2 {
|
||||
t.Errorf("Put did not overwrite: %v", v)
|
||||
}
|
||||
if c.Len() != 1 {
|
||||
t.Errorf("Len = %d, want 1", c.Len())
|
||||
}
|
||||
c.Delete("a")
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Error("deleted key still present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTLExpiry(t *testing.T) {
|
||||
c, clk := newTestCache[string, int](t, 4, time.Minute)
|
||||
c.Put("a", 1)
|
||||
|
||||
clk.advance(59 * time.Second)
|
||||
if _, ok := c.Get("a"); !ok {
|
||||
t.Error("entry expired early")
|
||||
}
|
||||
clk.advance(time.Second)
|
||||
if _, ok := c.Get("a"); ok {
|
||||
t.Error("entry outlived its TTL")
|
||||
}
|
||||
if c.Len() != 0 {
|
||||
t.Errorf("expired entry not dropped: Len = %d", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// The bound is the whole point: the rate limiter's key space is whatever
|
||||
// addresses show up, so an unbounded map would be a memory DoS.
|
||||
func TestEvictsLeastRecentlyUsed(t *testing.T) {
|
||||
c, _ := newTestCache[string, int](t, 3, time.Minute)
|
||||
c.Put("a", 1)
|
||||
c.Put("b", 2)
|
||||
c.Put("c", 3)
|
||||
|
||||
// Touching "a" makes "b" the least recently used.
|
||||
if _, ok := c.Get("a"); !ok {
|
||||
t.Fatal("a missing")
|
||||
}
|
||||
c.Put("d", 4)
|
||||
|
||||
if c.Len() != 3 {
|
||||
t.Errorf("Len = %d, want 3", c.Len())
|
||||
}
|
||||
if _, ok := c.Get("b"); ok {
|
||||
t.Error("b should have been evicted")
|
||||
}
|
||||
for _, k := range []string{"a", "c", "d"} {
|
||||
if _, ok := c.Get(k); !ok {
|
||||
t.Errorf("%s should have survived", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvictionKeepsListConsistent(t *testing.T) {
|
||||
c, _ := newTestCache[int, int](t, 8, time.Minute)
|
||||
for i := 0; i < 1000; i++ {
|
||||
c.Put(i, i)
|
||||
if i%3 == 0 {
|
||||
c.Get(i / 2)
|
||||
}
|
||||
if i%7 == 0 {
|
||||
c.Delete(i - 1)
|
||||
}
|
||||
if c.Len() > 8 {
|
||||
t.Fatalf("cap exceeded at i=%d: Len = %d", i, c.Len())
|
||||
}
|
||||
}
|
||||
// Every surviving node must be reachable from both ends, or a later
|
||||
// eviction would corrupt the list rather than free anything.
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
forward := 0
|
||||
for n := c.head; n != nil; n = n.next {
|
||||
forward++
|
||||
if forward > 100 {
|
||||
t.Fatal("forward walk did not terminate (cycle in the list)")
|
||||
}
|
||||
}
|
||||
backward := 0
|
||||
for n := c.tail; n != nil; n = n.prev {
|
||||
backward++
|
||||
if backward > 100 {
|
||||
t.Fatal("backward walk did not terminate (cycle in the list)")
|
||||
}
|
||||
}
|
||||
if forward != len(c.m) || backward != len(c.m) {
|
||||
t.Errorf("list holds %d/%d nodes, map holds %d", forward, backward, len(c.m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreate(t *testing.T) {
|
||||
c, clk := newTestCache[string, *int](t, 4, time.Minute)
|
||||
calls := 0
|
||||
newVal := func() *int {
|
||||
calls++
|
||||
v := calls
|
||||
return &v
|
||||
}
|
||||
|
||||
first := c.GetOrCreate("a", newVal)
|
||||
second := c.GetOrCreate("a", newVal)
|
||||
if first != second {
|
||||
t.Error("GetOrCreate must return the same value for a live entry")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("newVal called %d times, want 1", calls)
|
||||
}
|
||||
|
||||
clk.advance(2 * time.Minute)
|
||||
third := c.GetOrCreate("a", newVal)
|
||||
if third == first {
|
||||
t.Error("an expired entry must be replaced")
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("newVal called %d times, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Two callers racing on the same key must end up sharing one value — the rate
|
||||
// limiter relies on this to avoid handing every request its own token bucket.
|
||||
func TestGetOrCreateIsAtomic(t *testing.T) {
|
||||
c := New[string, *int](64, time.Minute)
|
||||
const goroutines = 32
|
||||
var wg sync.WaitGroup
|
||||
got := make([]*int, goroutines)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < goroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
got[i] = c.GetOrCreate("shared", func() *int { v := 0; return &v })
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
for i := 1; i < goroutines; i++ {
|
||||
if got[i] != got[0] {
|
||||
t.Fatalf("goroutine %d got a different value", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentUse(t *testing.T) {
|
||||
c := New[string, int](16, time.Minute)
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 500; i++ {
|
||||
k := fmt.Sprintf("k%d", i%40)
|
||||
c.Put(k, i)
|
||||
c.Get(k)
|
||||
if i%10 == 0 {
|
||||
c.Delete(k)
|
||||
}
|
||||
c.GetOrCreate(k, func() int { return g })
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
if c.Len() > 16 {
|
||||
t.Errorf("Len = %d, want <= 16", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClampsMax(t *testing.T) {
|
||||
c := New[string, int](0, time.Minute)
|
||||
c.Put("a", 1)
|
||||
c.Put("b", 2)
|
||||
if c.Len() != 1 {
|
||||
t.Errorf("Len = %d, want 1", c.Len())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user