mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
feat(api): add grant liveness cache
This commit is contained in:
parent
21ce7c7115
commit
7dd8dba12b
2 changed files with 171 additions and 0 deletions
87
core/apiauth/cache.go
Normal file
87
core/apiauth/cache.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
84
core/apiauth/cache_test.go
Normal file
84
core/apiauth/cache_test.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue