package beads
import (
"sync"
"time"
)
// --- the cache the cross-database readings share ------------------------------
//
// Two readings here open every database one caller may browse: /ready's
// aggregation (ReadyAcross) and the prefix index behind cross-database issue
// links (PrefixesAcross). Opening N stores per request is exactly what the
// per-request browse discipline does not scale to, and both are bounded the same
// way:
//
// 1. A head-hash gate. Opening a session and listing branches is cheap; reading
// and projecting rows is not. When the head has not moved, the cached
// projection stands and no row is read.
// 2. A TTL (ReadyCacheTTL), so a cache can never be the reason a reader sees
// yesterday's answer.
// 3. A ceiling on how many databases one call opens (ReadyMaxDatabases),
// applied by the aggregations themselves.
//
// The bounds are one set of numbers, named in ready.go where /ready needed them
// first. What differs between the two readings is only the projection being
// cached, which is what the type parameter is: a second cache with its own
// lifetime rules is how two pages start disagreeing about how fresh "fresh" is.
//
// What is cached is always a projection and never an open session. An open store
// is a file handle and a memory mapping; caching those is the thing per-request
// opening exists to prevent.
// cached is one database's cached projection plus the two facts the bounds are
// checked against: the head it was read at, and when it was stored.
type cached[T any] struct {
head string
at time.Time
value T
}
// projectionCache is a small mutex-guarded map from repository id to one cached
// projection. The repository id is the identity it is keyed on: no two databases
// share it and it survives a rename.
//
// The zero value is usable — the map is allocated on first store — so a cache
// can be a field of a value that has no constructor.
type projectionCache[T any] struct {
mu sync.Mutex
entries map[int]cached[T]
}
// lookup returns the cached projection for a database when it was read at the
// same head and has not expired. Both conditions, not either: the head hash is
// what makes it correct, the TTL is what makes it bounded.
func (c *projectionCache[T]) lookup(id int, head string, now time.Time) (T, bool) {
var zero T
if head == "" {
// A database whose head cannot be named cannot be gated on one.
return zero, false
}
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.entries[id]
if !ok || e.head != head || now.Sub(e.at) >= ReadyCacheTTL {
return zero, false
}
return e.value, true
}
// store records a projection read at head, dropping expired entries — and, if
// that was not enough, everything — when the map is at its ceiling. A cache is
// not a store: over the ceiling it starts again rather than growing with the
// instance.
func (c *projectionCache[T]) store(id int, head string, now time.Time, value T) {
if head == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = make(map[int]cached[T])
}
if len(c.entries) >= readyCacheMaxEntries {
for k, old := range c.entries {
if now.Sub(old.at) >= ReadyCacheTTL {
delete(c.entries, k)
}
}
if len(c.entries) >= readyCacheMaxEntries {
c.entries = make(map[int]cached[T], readyCacheMaxEntries)
}
}
c.entries[id] = cached[T]{head: head, at: now, value: value}
}
// size reports how many entries the cache holds. It exists for the tests that
// assert the ceiling: the bound is the point of the map, and only a count can
// check it.
func (c *projectionCache[T]) size() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.entries)
}