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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
|
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 -- and, since
// ef_transfer_plan_cache.go v2, efOccupancyIndex's result -- actually depend
// on, established by reading efTransferPlan, celebrationDate, temporalEF and
// resolveDate rather than assumed. Both the plan and the occupancy index are
// pure functions of exactly these three inputs (see efOccupancyIndex's own
// doc comment for occupancy specifically), so one key correctly covers both:
//
// - 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, occupancy index) pair,
// doubling as the LRU list's payload so eviction and lookup share one map.
// The two are cached together because they are built from the same data in
// one pass and share the same cache key -- see efTransferPlanCacheKey.
type efTransferPlanCacheEntry struct {
key efTransferPlanCacheKey
plan map[string]time.Time
occupancy efOccupancyIndex
}
// 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()}
// efOccupancyEntry is one merged entry's slug and rank, indexed under its own
// ORIGINAL (untransferred) date -- see efOccupancyIndex.
type efOccupancyEntry struct {
slug string
rank Rank
}
// efOccupancyIndex answers exactly the question computeEF's old
// occupiedByRank closure scanned all of merged for on every single call:
// "does some entry other than exceptSlug, with a rank `allowed` accepts,
// resolve to date d this year?" -- but as a precomputed lookup instead of a
// fresh O(len(merged)) scan.
//
// BE PRECISE ABOUT WHAT "OCCUPIED" DEPENDS ON, because this is the question
// that decides whether precomputing it is safe: an entry's ORIGINAL date
// (buildCelebration + celebrationDate, exactly as this index computes it) is
// a pure function of (merged content, year, sel) alone -- it never considers
// whether that entry has since been TRANSFERRED (celebrationDate calls
// resolveDate directly; nothing about transfer resolution feeds into it) and
// never depends on `date`, the day computeEF happens to be resolving. That
// is what makes one index, built once per (year, merged, sel) and reused for
// every day and every candidate resolved against it, correct.
//
// This is a DIFFERENT question from efTransferPlan's own `claimed` map,
// which tracks which TARGET days a transfer walk has already assigned
// DURING one planning pass -- claimed genuinely mutates as planning
// proceeds and has no meaning outside that single call, so it is (correctly)
// still computed fresh inside efTransferPlan every time, never cached here.
// Conflating the two -- treating "is some entry's ORIGINAL date d" as if it
// captured "has some transfer already CLAIMED d" -- would be the exact
// silent-wrong-answer failure mode this comment exists to rule out; they are
// checked as three independent conditions everywhere they are used
// together (see efTransferPlan's own forward-walk loop).
type efOccupancyIndex map[string][]efOccupancyEntry // "2006-01-02" -> entries
// buildEFOccupancyIndex computes the index in one O(len(merged)) pass.
func buildEFOccupancyIndex(merged map[string]RawCelebration, year int, sel Selection) efOccupancyIndex {
idx := efOccupancyIndex{}
for slug, rc := range merged {
cel := buildCelebration(slug, rc)
when, ok := celebrationDate(cel, year, sel)
if !ok {
continue
}
key := when.Format("2006-01-02")
idx[key] = append(idx[key], efOccupancyEntry{slug: slug, rank: cel.Rank})
}
return idx
}
// occupied reports whether some entry other than exceptSlug at date d has a
// rank allowed accepts -- computeEF's former occupiedByRank closure's exact
// semantics, read from the precomputed index instead of a fresh scan. Safe
// to call concurrently: idx is never mutated after buildEFOccupancyIndex
// returns it (Go's map type permits unlimited concurrent reads with no
// concurrent write).
func (idx efOccupancyIndex) occupied(d time.Time, exceptSlug string, allowed func(Rank) bool) bool {
for _, e := range idx[d.Format("2006-01-02")] {
if e.slug == exceptSlug {
continue
}
if allowed(e.rank) {
return true
}
}
return false
}
// efTransferPlanCached is (efTransferPlan, buildEFOccupancyIndex), memoised
// together 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/buildEFOccupancyIndex
// themselves 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 plan is a fresh copy per call
// (clonePlan), never the cached instance, so no caller can mutate shared
// cache state even though computeEF's own use of it is read-only today. The
// occupancy index is returned WITHOUT copying (unlike plan, it can hold one
// entry per merged slug, up to ~330 on shipped data, so copying it on every
// call -- most of them hits -- would give back a real slice of the win this
// exists to capture); this is safe only because it is genuinely immutable
// after construction (see efOccupancyIndex's own doc comment) and no code
// anywhere writes to a returned index.
func efTransferPlanCached(year int, merged map[string]RawCelebration, sel Selection) (map[string]time.Time, efOccupancyIndex) {
key := efTransferPlanCacheKey{year: year, sel: sel, hash: hashMergedForPlan(merged)}
efTransferPlanCache.mu.Lock()
if el, ok := efTransferPlanCache.entries[key]; ok {
efTransferPlanCache.order.MoveToFront(el)
entry := el.Value.(*efTransferPlanCacheEntry)
plan, occ := entry.plan, entry.occupancy
efTransferPlanCache.mu.Unlock()
return clonePlan(plan), occ
}
efTransferPlanCache.mu.Unlock()
// Compute outside the lock: building the index and the plan is the
// expensive work 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.
occ := buildEFOccupancyIndex(merged, year, sel)
plan := efTransferPlan(year, merged, sel, occ)
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)
entry := el.Value.(*efTransferPlanCacheEntry)
return clonePlan(entry.plan), entry.occupancy
}
el := efTransferPlanCache.order.PushFront(&efTransferPlanCacheEntry{key: key, plan: plan, occupancy: occ})
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), occ
}
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 the plan's or the
// occupancy index's own read paths happen to touch today (Rank, Date via
// celebrationDate). efOccupancyIndex indexes ALL of merged at every rank
// (not just class-1/2 -- occ.occupied's `allowed` parameter is arbitrary),
// 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 or index 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})
}
}
|