| 1 |
|
package beads |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"sync" |
| 5 |
|
"time" |
| 6 |
|
) |
| 7 |
|
|
| 8 |
|
// --- the cache the cross-database readings share ------------------------------ |
| 9 |
|
// |
| 10 |
|
// Two readings here open every database one caller may browse: /ready's |
| 11 |
|
// aggregation (ReadyAcross) and the prefix index behind cross-database issue |
| 12 |
|
// links (PrefixesAcross). Opening N stores per request is exactly what the |
| 13 |
|
// per-request browse discipline does not scale to, and both are bounded the same |
| 14 |
|
// way: |
| 15 |
|
// |
| 16 |
|
// 1. A head-hash gate. Opening a session and listing branches is cheap; reading |
| 17 |
|
// and projecting rows is not. When the head has not moved, the cached |
| 18 |
|
// projection stands and no row is read. |
| 19 |
|
// 2. A TTL (ReadyCacheTTL), so a cache can never be the reason a reader sees |
| 20 |
|
// yesterday's answer. |
| 21 |
|
// 3. A ceiling on how many databases one call opens (ReadyMaxDatabases), |
| 22 |
|
// applied by the aggregations themselves. |
| 23 |
|
// |
| 24 |
|
// The bounds are one set of numbers, named in ready.go where /ready needed them |
| 25 |
|
// first. What differs between the two readings is only the projection being |
| 26 |
|
// cached, which is what the type parameter is: a second cache with its own |
| 27 |
|
// lifetime rules is how two pages start disagreeing about how fresh "fresh" is. |
| 28 |
|
// |
| 29 |
|
// What is cached is always a projection and never an open session. An open store |
| 30 |
|
// is a file handle and a memory mapping; caching those is the thing per-request |
| 31 |
|
// opening exists to prevent. |
| 32 |
|
|
| 33 |
|
// cached is one database's cached projection plus the two facts the bounds are |
| 34 |
|
// checked against: the head it was read at, and when it was stored. |
| 35 |
|
type cached[T any] struct { |
| 36 |
|
head string |
| 37 |
|
at time.Time |
| 38 |
|
value T |
| 39 |
|
} |
| 40 |
|
|
| 41 |
|
// projectionCache is a small mutex-guarded map from repository id to one cached |
| 42 |
|
// projection. The repository id is the identity it is keyed on: no two databases |
| 43 |
|
// share it and it survives a rename. |
| 44 |
|
// |
| 45 |
|
// The zero value is usable — the map is allocated on first store — so a cache |
| 46 |
|
// can be a field of a value that has no constructor. |
| 47 |
|
type projectionCache[T any] struct { |
| 48 |
|
mu sync.Mutex |
| 49 |
|
entries map[int]cached[T] |
| 50 |
|
} |
| 51 |
|
|
| 52 |
|
// lookup returns the cached projection for a database when it was read at the |
| 53 |
|
// same head and has not expired. Both conditions, not either: the head hash is |
| 54 |
|
// what makes it correct, the TTL is what makes it bounded. |
| 55 |
209 |
func (c *projectionCache[T]) lookup(id int, head string, now time.Time) (T, bool) { |
| 56 |
209 |
var zero T |
| 57 |
209 |
if head == "" { |
| 58 |
0 |
// A database whose head cannot be named cannot be gated on one. |
| 59 |
0 |
return zero, false |
| 60 |
0 |
} |
| 61 |
209 |
c.mu.Lock() |
| 62 |
209 |
defer c.mu.Unlock() |
| 63 |
209 |
e, ok := c.entries[id] |
| 64 |
209 |
if !ok || e.head != head || now.Sub(e.at) >= ReadyCacheTTL { |
| 65 |
193 |
return zero, false |
| 66 |
193 |
} |
| 67 |
16 |
return e.value, true |
| 68 |
|
} |
| 69 |
|
|
| 70 |
|
// store records a projection read at head, dropping expired entries — and, if |
| 71 |
|
// that was not enough, everything — when the map is at its ceiling. A cache is |
| 72 |
|
// not a store: over the ceiling it starts again rather than growing with the |
| 73 |
|
// instance. |
| 74 |
459 |
func (c *projectionCache[T]) store(id int, head string, now time.Time, value T) { |
| 75 |
459 |
if head == "" { |
| 76 |
0 |
return |
| 77 |
0 |
} |
| 78 |
459 |
c.mu.Lock() |
| 79 |
459 |
defer c.mu.Unlock() |
| 80 |
459 |
if c.entries == nil { |
| 81 |
40 |
c.entries = make(map[int]cached[T]) |
| 82 |
40 |
} |
| 83 |
459 |
if len(c.entries) >= readyCacheMaxEntries { |
| 84 |
256 |
for k, old := range c.entries { |
| 85 |
256 |
if now.Sub(old.at) >= ReadyCacheTTL { |
| 86 |
0 |
delete(c.entries, k) |
| 87 |
0 |
} |
| 88 |
|
} |
| 89 |
1 |
if len(c.entries) >= readyCacheMaxEntries { |
| 90 |
1 |
c.entries = make(map[int]cached[T], readyCacheMaxEntries) |
| 91 |
1 |
} |
| 92 |
|
} |
| 93 |
459 |
c.entries[id] = cached[T]{head: head, at: now, value: value} |
| 94 |
|
} |
| 95 |
|
|
| 96 |
|
// size reports how many entries the cache holds. It exists for the tests that |
| 97 |
|
// assert the ceiling: the bound is the point of the map, and only a count can |
| 98 |
|
// check it. |
| 99 |
1 |
func (c *projectionCache[T]) size() int { |
| 100 |
1 |
c.mu.Lock() |
| 101 |
1 |
defer c.mu.Unlock() |
| 102 |
1 |
return len(c.entries) |
| 103 |
1 |
} |