diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 10:45:28 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 10:45:28 +0200 |
| commit | f110c338718a6eea3693fb6afb4f7fcc5b77003b (patch) | |
| tree | 52b6adb382445a0762f63fb862361d7fbd8637f2 /internal/calendar/transfer_plan_cache.go | |
| parent | fd3eda0da228d2417b8b7f9007533e214d7a5e80 (diff) | |
| parent | b24702cc9b8d7f2e408bb92032e8f7fa2aaa836b (diff) | |
| download | lectio-f110c338718a6eea3693fb6afb4f7fcc5b77003b.tar.gz lectio-f110c338718a6eea3693fb6afb4f7fcc5b77003b.zip | |
merge: make the calendar engine stop repeating whole-year work per day
A seven-day view resolved the year seven times. Each readings.Load stacked
the calendar data layers and re-read the Bible book table -- both
date-independent -- and the EF path rebuilt the year's entire transfer plan
and its occupancy scan on every single day.
Three logic-neutral fixes: hoist the date-independent setup out of the Days
loop; memoise the EF transfer plan on (year, Selection, content hash of the
merged sanctoral); and build the occupancy index once alongside it rather
than scanning per candidate.
EF's seven-day view goes 74ms to 16ms, its multiple over OF from ~13x to
~3x. OF is unchanged, as it never touched the EF paths.
Output is byte-identical throughout, proven by SHA-256 over a 492-case
sweep spanning both forms, both languages, four corpora, the Triduum, a
Requiem day and the three Joseph/Annunciation collision years. The caches
are keyed on content, not identity, so a user overlay invalidates them --
asserted by a test that varies the overlay and requires the answer to
change, itself mutation-proved by dropping the content hash and watching it
fail.
Diffstat (limited to 'internal/calendar/transfer_plan_cache.go')
| -rw-r--r-- | internal/calendar/transfer_plan_cache.go | 269 |
1 files changed, 269 insertions, 0 deletions
diff --git a/internal/calendar/transfer_plan_cache.go b/internal/calendar/transfer_plan_cache.go new file mode 100644 index 0000000..f03e17b --- /dev/null +++ b/internal/calendar/transfer_plan_cache.go @@ -0,0 +1,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}) + } +} |
