~bigbes/sr-ht-dolt

sr-ht-dolt/beads/cache.go -rw-r--r-- 3.6 KiB
3523280c — Eugene Blikh beads: ignore the JSONL exports a day ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
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)
}