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) }