This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+179
View File
@@ -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
}
+223
View File
@@ -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())
}
}