diff --git a/core/apiauth/cache.go b/core/apiauth/cache.go new file mode 100644 index 000000000..136b5c336 --- /dev/null +++ b/core/apiauth/cache.go @@ -0,0 +1,87 @@ +package apiauth + +import ( + "maps" + "sync" + "time" +) + +const maxLivenessEntries = 1024 + +type livenessEntry struct { + userID string + epoch int + lastUsedAt time.Time + expires time.Time +} + +// livenessCache bounds how long a node trusts "this grant exists" without asking the DB. +type livenessCache struct { + mu sync.Mutex + ttl time.Duration + gen uint64 + entries map[string]livenessEntry + evicted map[string]uint64 // grant id -> generation of its last eviction + floor uint64 // fills started before the last trim of evicted are dropped +} + +func newLivenessCache(ttl time.Duration) *livenessCache { + return &livenessCache{ttl: ttl, entries: map[string]livenessEntry{}, evicted: map[string]uint64{}} +} + +func (c *livenessCache) begin() uint64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.gen +} + +func (c *livenessCache) get(id string, now time.Time) (livenessEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[id] + if !ok || !now.Before(e.expires) { + return livenessEntry{}, false + } + return e, true +} + +// put ignores a fill whose DB read started before the grant was last evicted. +func (c *livenessCache) put(id string, e livenessEntry, now time.Time, started uint64) { + c.mu.Lock() + defer c.mu.Unlock() + if started < c.floor || c.evicted[id] > started { + return + } + if len(c.entries) >= maxLivenessEntries { + maps.DeleteFunc(c.entries, func(_ string, v livenessEntry) bool { return !now.Before(v.expires) }) + } + if len(c.evicted) >= maxLivenessEntries { + clear(c.evicted) + c.floor = c.gen + } + e.expires = now.Add(c.ttl) + c.entries[id] = e +} + +func (c *livenessCache) evict(id string) { + c.mu.Lock() + defer c.mu.Unlock() + c.gen++ + c.evicted[id] = c.gen + delete(c.entries, id) +} + +func (c *livenessCache) markUsed(id string, at time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + if e, ok := c.entries[id]; ok { + e.lastUsedAt = at + c.entries[id] = e + } +} + +func (c *livenessCache) len() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.entries) +} diff --git a/core/apiauth/cache_test.go b/core/apiauth/cache_test.go new file mode 100644 index 000000000..999f3e24a --- /dev/null +++ b/core/apiauth/cache_test.go @@ -0,0 +1,84 @@ +package apiauth + +import ( + "fmt" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("livenessCache", func() { + var c *livenessCache + var t0 time.Time + + BeforeEach(func() { + c = newLivenessCache(30 * time.Second) + t0 = time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) + }) + + It("returns an entry until its TTL passes", func() { + c.put("g1", livenessEntry{userID: "u1", epoch: 2}, t0, c.begin()) + e, ok := c.get("g1", t0.Add(29*time.Second)) + Expect(ok).To(BeTrue()) + Expect(e.userID).To(Equal("u1")) + Expect(e.epoch).To(Equal(2)) + _, ok = c.get("g1", t0.Add(30*time.Second)) + Expect(ok).To(BeFalse()) + }) + + It("forgets evicted entries", func() { + c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) + c.evict("g1") + _, ok := c.get("g1", t0) + Expect(ok).To(BeFalse()) + }) + + It("drops a fill that started before an eviction of the same grant", func() { + started := c.begin() // a request reads the grant from the DB... + c.evict("g1") // ...a logout deletes and evicts it... + c.put("g1", livenessEntry{userID: "u1"}, t0, started) // ...then the slow request tries to cache it + _, ok := c.get("g1", t0) + Expect(ok).To(BeFalse()) + }) + + It("still accepts fills of other grants and later fills of the same grant", func() { + started := c.begin() + c.evict("g1") + c.put("g2", livenessEntry{userID: "u2"}, t0, started) + _, ok := c.get("g2", t0) + Expect(ok).To(BeTrue()) + c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) + _, ok = c.get("g1", t0) + Expect(ok).To(BeTrue()) + }) + + It("still drops a racing fill after the eviction log is trimmed", func() { + started := c.begin() + c.evict("g1") + for i := range maxLivenessEntries { + c.evict(fmt.Sprint("other", i)) + } + c.put("fresh", livenessEntry{}, t0, c.begin()) + c.put("g1", livenessEntry{userID: "u1"}, t0, started) + _, ok := c.get("g1", t0) + Expect(ok).To(BeFalse()) + }) + + It("records the last use without extending the TTL", func() { + c.put("g1", livenessEntry{userID: "u1"}, t0, c.begin()) + c.markUsed("g1", t0.Add(10*time.Second)) + e, _ := c.get("g1", t0.Add(11*time.Second)) + Expect(e.lastUsedAt).To(Equal(t0.Add(10 * time.Second))) + _, ok := c.get("g1", t0.Add(30*time.Second)) + Expect(ok).To(BeFalse()) + }) + + It("drops expired entries when it grows", func() { + for i := range maxLivenessEntries { + c.put(fmt.Sprint(i), livenessEntry{}, t0, c.begin()) + } + c.put("fresh", livenessEntry{}, t0.Add(time.Minute), c.begin()) + Expect(c.len()).To(Equal(1)) + }) +})