aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar/transfer_plan_cache.go
blob: fdf62f7f0dc1ffd11d5976523ef441f1d76ef712 (plain) (blame)
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
package calendar

import (
	"container/list"
	"crypto/sha256"
	"io"
	"sort"
	"sync"
	"time"
)

// efTransferPlanCacheCap bounds the memoised EF transfer-plan cache (below)
// so a long-running gomobile process cannot grow it without bound as a user
// scrolls through many years -- capacity 8417 (the whole 1583-9999 domain)
// would be a real, if small, leak risk over a long enough session; 64 keeps
// memory negligible (each entry is a handful of time.Time values) while
// still giving a warm cache for the realistic access pattern, a week/month
// view moving through nearby dates.
const efTransferPlanCacheCap = 64

// efTransferPlanCacheKey is everything efTransferPlan's result actually
// depends on, established by reading efTransferPlan, celebrationDate,
// temporalEF and resolveDate rather than assumed:
//
//   - year: efTransferPlan's own first argument (Easter, the transfer
//     window, every celebrationDate call).
//   - sel: passed through to celebrationDate on every candidate, which
//     currently ignores it (resolveDate never reads its sel parameter) --
//     so it is a dead dependency TODAY. It is still part of the key: the
//     parameter exists on the signature for a reason, and keying on it now
//     costs nothing (Selection is four small strings) while protecting
//     against a future change that makes EF date resolution
//     Selection-sensitive silently poisoning a cache that never accounted
//     for it.
//   - a content hash of merged: efTransferPlan's real data dependency.
//     merged is a map -- not itself comparable, so cannot be a map key
//     field -- and mergeLayers rebuilds a brand-new map with fresh Go map
//     iteration order on every call even when the underlying layers are
//     unchanged, so identity/pointer comparison cannot be used either only
//     a hash of merged's actual content is both correct (invalidates
//     whenever the content genuinely differs -- a user overlay edited, a
//     different layer stack) and cache-friendly (hits whenever it does not,
//     regardless of which Layer objects or map iteration produced it). See
//     hashMergedForPlan for what is and is not hashed.
type efTransferPlanCacheKey struct {
	year int
	sel  Selection
	hash [32]byte
}

// efTransferPlanCacheEntry is one memoised plan, doubling as the LRU list's
// payload so eviction and lookup share one map.
type efTransferPlanCacheEntry struct {
	key  efTransferPlanCacheKey
	plan map[string]time.Time
}

// efTransferPlanCache is a small, bounded, thread-safe LRU in front of
// efTransferPlan. gomobile may call Day/Days in from multiple goroutines, so
// the map and list are guarded by one mutex; efTransferPlan itself (the
// expensive part) runs OUTSIDE the lock so concurrent misses on different
// keys do not serialise behind each other -- see efTransferPlanCached.
var efTransferPlanCache = struct {
	mu      sync.Mutex
	entries map[efTransferPlanCacheKey]*list.Element
	order   *list.List // front = most recently used
}{entries: map[efTransferPlanCacheKey]*list.Element{}, order: list.New()}

// efTransferPlanCached is efTransferPlan, memoised by (year, sel, merged
// content). It is safe and correct to share across every caller in the
// process: two calls with the SAME key are, by construction of the key,
// calls efTransferPlan itself would resolve identically (same year, same
// Selection, same sanctoral content), so returning a cached result changes
// nothing about what is computed -- only when. The returned map is a fresh
// copy per call (clonePlan), never the cached instance itself, so no caller
// can mutate shared cache state even though computeEF's own use of the
// result is read-only today.
func efTransferPlanCached(year int, merged map[string]RawCelebration, sel Selection,
	occupiedByClass1, occupiedByClass1Or2 func(time.Time, string) bool) map[string]time.Time {

	key := efTransferPlanCacheKey{year: year, sel: sel, hash: hashMergedForPlan(merged)}

	efTransferPlanCache.mu.Lock()
	if el, ok := efTransferPlanCache.entries[key]; ok {
		efTransferPlanCache.order.MoveToFront(el)
		plan := el.Value.(*efTransferPlanCacheEntry).plan
		efTransferPlanCache.mu.Unlock()
		return clonePlan(plan)
	}
	efTransferPlanCache.mu.Unlock()

	// Compute outside the lock: efTransferPlan is the expensive call this
	// cache exists to avoid repeating, and holding the mutex across it would
	// serialise every concurrent miss on DIFFERENT keys, not just protect
	// the shared map/list.
	plan := efTransferPlan(year, merged, sel, occupiedByClass1, occupiedByClass1Or2)

	efTransferPlanCache.mu.Lock()
	defer efTransferPlanCache.mu.Unlock()
	if el, ok := efTransferPlanCache.entries[key]; ok {
		// Lost a race: another goroutine populated this exact key while we
		// were computing our own copy unlocked. Same key => same result by
		// construction, so keep the existing entry and just bump recency.
		efTransferPlanCache.order.MoveToFront(el)
		return clonePlan(el.Value.(*efTransferPlanCacheEntry).plan)
	}
	el := efTransferPlanCache.order.PushFront(&efTransferPlanCacheEntry{key: key, plan: plan})
	efTransferPlanCache.entries[key] = el
	if efTransferPlanCache.order.Len() > efTransferPlanCacheCap {
		oldest := efTransferPlanCache.order.Back()
		efTransferPlanCache.order.Remove(oldest)
		delete(efTransferPlanCache.entries, oldest.Value.(*efTransferPlanCacheEntry).key)
	}
	return clonePlan(plan)
}

func clonePlan(plan map[string]time.Time) map[string]time.Time {
	out := make(map[string]time.Time, len(plan))
	for k, v := range plan {
		out[k] = v
	}
	return out
}

// hashMergedForPlan hashes merged's full content deterministically. Go's map
// iteration order is randomised per-process, so slugs (and, within each
// entry, its Fields and Variant keys) are sorted before hashing -- the same
// logical content always hashes identically, regardless of which Layer
// stack produced it or what order ranging over it visits entries.
//
// EVERY field of every entry is hashed, not just the ones efTransferPlan's
// own read path happens to touch today (Rank, Date via celebrationDate).
// occupiedByClass1/Or2 (closures over merged, passed in by computeEF) scan
// ALL of merged looking for competing entries of any rank, and which
// entries even qualify as class-1 in the first place is ITSELF computed
// from this data (buildCelebration's "rank" field) -- so hashing only a
// subset of fields would risk exactly the silent-stale-cache bug this
// function exists to prevent: a user overlay that changes, say, a
// "suppress" or an unrelated feast's rank would not change the hash, and a
// stale plan would keep being served.
func hashMergedForPlan(merged map[string]RawCelebration) [32]byte {
	slugs := make([]string, 0, len(merged))
	for slug := range merged {
		slugs = append(slugs, slug)
	}
	sort.Strings(slugs)

	h := sha256.New()
	for _, slug := range slugs {
		rc := merged[slug]
		io.WriteString(h, "slug=")
		io.WriteString(h, slug)
		h.Write([]byte{0})
		writeSortedFields(h, rc.Fields)
		variants := make([]string, 0, len(rc.Variants))
		for v := range rc.Variants {
			variants = append(variants, v)
		}
		sort.Strings(variants)
		for _, v := range variants {
			io.WriteString(h, "variant=")
			io.WriteString(h, v)
			h.Write([]byte{0})
			writeSortedFields(h, rc.Variants[v])
		}
		h.Write([]byte{1}) // record separator, so "ab"+"c" cannot collide with "a"+"bc"
	}
	var sum [32]byte
	copy(sum[:], h.Sum(nil))
	return sum
}

// writeSortedFields writes fields into h as sorted "key=value\x00" records
// (see hashMergedForPlan).
func writeSortedFields(h io.Writer, fields map[string]string) {
	keys := make([]string, 0, len(fields))
	for k := range fields {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		io.WriteString(h, k)
		h.Write([]byte{'='})
		io.WriteString(h, fields[k])
		h.Write([]byte{0})
	}
}