aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar
diff options
context:
space:
mode:
Diffstat (limited to 'internal/calendar')
-rw-r--r--internal/calendar/calendar.go9
-rw-r--r--internal/calendar/transfer_plan_cache.go187
-rw-r--r--internal/calendar/transfer_plan_cache_test.go191
3 files changed, 386 insertions, 1 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go
index d918547..00048b7 100644
--- a/internal/calendar/calendar.go
+++ b/internal/calendar/calendar.go
@@ -67,7 +67,14 @@ func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
// RG 97/98: the year's impeded I-class transfers are resolved as a set,
// not one at a time, so two feasts impeded by the same early Easter cannot
// both claim the same free day and lose one of themselves.
- plan := efTransferPlan(year, merged, sel, occ1, occ1Or2)
+ //
+ // efTransferPlan is pure in (year, merged content, sel) and identical for
+ // every day of the year it is asked about -- computeEF runs once PER DAY,
+ // so a multi-day view (mobile.Days's week, a month view) was rebuilding
+ // it from scratch on every single one. efTransferPlanCached memoises it;
+ // see transfer_plan_cache.go for the cache key and why each of its three
+ // parts is load-bearing.
+ plan := efTransferPlanCached(year, merged, sel, occ1, occ1Or2)
cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season, Sunday: td.Sunday}}
for slug, rc := range merged {
cel := buildCelebration(slug, rc)
diff --git a/internal/calendar/transfer_plan_cache.go b/internal/calendar/transfer_plan_cache.go
new file mode 100644
index 0000000..fdf62f7
--- /dev/null
+++ b/internal/calendar/transfer_plan_cache.go
@@ -0,0 +1,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})
+ }
+}
diff --git a/internal/calendar/transfer_plan_cache_test.go b/internal/calendar/transfer_plan_cache_test.go
new file mode 100644
index 0000000..6b3b48b
--- /dev/null
+++ b/internal/calendar/transfer_plan_cache_test.go
@@ -0,0 +1,191 @@
+package calendar_test
+
+// Tests for the memoised EF transfer plan (transfer_plan_cache.go). These
+// live in the external test package (calendar_test), exercising the real
+// tridentine sanctoral data through the public Compute entry point, the same
+// style precedence_ef_repro_test.go already uses -- the risk this cache
+// introduces is entirely about whether the CACHE KEY captures every real
+// input, which can only be demonstrated by varying an input through Compute
+// and checking the observed office actually changes, not by testing the
+// cache's internals in isolation.
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/caldata"
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+)
+
+// TestEFTransferPlanCacheInvalidatesOnOverlay is THE bad-key test: it warms
+// the memoised plan for (2008, EF, the shipped calendar) across a whole
+// week -- mobile.Days's own access pattern -- then asks the SAME year
+// through a layer stack with ONE user overlay added (suppressing the
+// Annunciation) and asserts the observed office on 31 March and 1 April
+// CHANGES accordingly.
+//
+// What is varied: the layer stack / merged sanctoral content (a user
+// overlay), holding year and Selection fixed. This is deliberately not a
+// synthetic scenario: with the Annunciation present, its RG 96(a) proper
+// seat (the Monday after Low Sunday, RG 97/98) forces St Joseph -- also
+// impeded that year -- to walk one day further, to 1 April
+// (TestEFTwoTransfersDoNotCollide already pins this). Suppress the
+// Annunciation and it no longer claims that Monday, so Joseph lands ON it,
+// 31 March, and 1 April reverts to an ordinary feria. Verified empirically
+// against the pre-cache code before this test was written (both outcomes
+// reproduced exactly as asserted below).
+//
+// A cache keyed on year alone -- or on year+sel without the merged content
+// -- would serve 2008's BASE-CALENDAR plan back for the overlaid query too,
+// since both share every other key component; this test fails loudly if
+// that happens (it would see 1 April still reporting Joseph, and 31 March
+// still reporting the Annunciation, from the first, unrelated warm-up).
+func TestEFTransferPlanCacheInvalidatesOnOverlay(t *testing.T) {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ base := caldata.Tridentine()
+ baseLayers := []calendar.Layer{base}
+
+ compute := func(layers []calendar.Layer, date string) string {
+ d, err := time.Parse("2006-01-02", date)
+ if err != nil {
+ t.Fatalf("bad test date %q: %v", date, err)
+ }
+ return calendar.Compute(d.UTC(), sel, layers).Observed.Slug
+ }
+
+ // Warm the cache for (2008, sel, base-only-hash) across a whole week,
+ // exactly like mobile.Days resolving seven consecutive dates against the
+ // same Prepared layer stack.
+ for _, date := range []string{
+ "2008-03-26", "2008-03-27", "2008-03-28", "2008-03-29",
+ "2008-03-30", "2008-03-31", "2008-04-01", "2008-04-02",
+ } {
+ compute(baseLayers, date)
+ }
+ if got := compute(baseLayers, "2008-03-31"); got != "annunciation-of-the-blessed-virgin-mary" {
+ t.Fatalf("baseline 2008-03-31 = %q, want annunciation-of-the-blessed-virgin-mary", got)
+ }
+ if got := compute(baseLayers, "2008-04-01"); got != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Fatalf("baseline 2008-04-01 = %q, want joseph-spouse-of-the-bl-virgin-mary", got)
+ }
+
+ // Same year, same Selection, ONE overlay layer added: suppress the
+ // Annunciation. If the cache key omitted the sanctoral content, these
+ // two calls would silently return the baseline plan warmed above.
+ overlay := calendar.Layer{ID: "user", Cels: map[string]calendar.RawCelebration{
+ "annunciation-of-the-blessed-virgin-mary": {
+ Fields: map[string]string{"suppress": "true"},
+ Variants: map[string]map[string]string{},
+ },
+ }}
+ overlaid := []calendar.Layer{base, overlay}
+
+ if got := compute(overlaid, "2008-03-31"); got != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("overlaid 2008-03-31 = %q, want joseph-spouse-of-the-bl-virgin-mary (Joseph now lands here, the Annunciation no longer claims it)", got)
+ }
+ if got := compute(overlaid, "2008-04-01"); got == "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("overlaid 2008-04-01 = %q, want NOT joseph (a stale cache hit from the base-calendar warm-up)", got)
+ }
+
+ // And the base calendar's own answer must be unaffected by having since
+ // computed the overlaid one -- the two keys must not collide either way.
+ if got := compute(baseLayers, "2008-03-31"); got != "annunciation-of-the-blessed-virgin-mary" {
+ t.Errorf("base calendar 2008-03-31 after overlaid query = %q, want annunciation-of-the-blessed-virgin-mary (unaffected)", got)
+ }
+ if got := compute(baseLayers, "2008-04-01"); got != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("base calendar 2008-04-01 after overlaid query = %q, want joseph-spouse-of-the-bl-virgin-mary (unaffected)", got)
+ }
+}
+
+// TestEFTransferPlanCacheInvalidatesOnYear is a lighter companion: the same
+// week-then-query pattern, varying the YEAR instead of the overlay (2008 vs
+// 2035, both real Joseph/Annunciation collision years -- see
+// TestEFTwoTransfersDoNotCollide -- but with different transfer targets).
+// year is an explicit field of the cache key already, so this mainly guards
+// against a key struct refactor accidentally dropping it; the overlay test
+// above is the one guarding the field that is easy to omit by mistake.
+func TestEFTransferPlanCacheInvalidatesOnYear(t *testing.T) {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ layers := []calendar.Layer{caldata.Tridentine()}
+
+ compute := func(date string) string {
+ d, err := time.Parse("2006-01-02", date)
+ if err != nil {
+ t.Fatalf("bad test date %q: %v", date, err)
+ }
+ return calendar.Compute(d.UTC(), sel, layers).Observed.Slug
+ }
+
+ if got := compute("2008-04-01"); got != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Fatalf("2008-04-01 = %q, want joseph-spouse-of-the-bl-virgin-mary", got)
+ }
+ // 2035's Joseph lands on 2035-04-03, not 04-01 (a later Easter shifts the
+ // whole window). If the cache ignored the year, this would wrongly
+ // return 2008's plan.
+ if got := compute("2035-04-01"); got == "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("2035-04-01 = %q, want NOT joseph (that is 2008's landing day, not 2035's)", got)
+ }
+ if got := compute("2035-04-03"); got != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("2035-04-03 = %q, want joseph-spouse-of-the-bl-virgin-mary", got)
+ }
+}
+
+// TestEFTransferPlanCacheConcurrentUse exercises the memoised plan from many
+// goroutines at once -- gomobile may call Day/Days in from multiple threads,
+// and none of the tests above (all sequential) can catch a data race on the
+// shared cache map/list. Run with -race; it is the actual proof of the
+// "thread-safe" claim, not merely built with a mutex and assumed correct.
+func TestEFTransferPlanCacheConcurrentUse(t *testing.T) {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ base := caldata.Tridentine()
+ overlay := calendar.Layer{ID: "user", Cels: map[string]calendar.RawCelebration{
+ "annunciation-of-the-blessed-virgin-mary": {
+ Fields: map[string]string{"suppress": "true"},
+ Variants: map[string]map[string]string{},
+ },
+ }}
+ baseLayers := []calendar.Layer{base}
+ overlaidLayers := []calendar.Layer{base, overlay}
+
+ years := []int{2008, 2011, 2035, 2046}
+
+ var wg sync.WaitGroup
+ for g := 0; g < 12; g++ {
+ g := g
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ layers := baseLayers
+ if g%2 == 0 {
+ layers = overlaidLayers // different goroutines hammer different cache keys
+ }
+ for i := 0; i < 6; i++ {
+ y := years[(g+i)%len(years)]
+ for _, md := range []string{"03-19", "03-31", "04-01"} {
+ d, err := time.Parse("2006-01-02", time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006")+"-"+md)
+ if err != nil {
+ t.Errorf("bad date: %v", err)
+ return
+ }
+ _ = calendar.Compute(d.UTC(), sel, layers).Observed.Slug
+ }
+ }
+ }()
+ }
+ wg.Wait()
+
+ // After the concurrent hammering, correctness must still hold for both
+ // keys -- the concurrency test is not a substitute for the correctness
+ // test above, so re-assert both outcomes here too.
+ d, _ := time.Parse("2006-01-02", "2008-04-01")
+ if got := calendar.Compute(d.UTC(), sel, baseLayers).Observed.Slug; got != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("after concurrent use, base 2008-04-01 = %q, want joseph-spouse-of-the-bl-virgin-mary", got)
+ }
+ if got := calendar.Compute(d.UTC(), sel, overlaidLayers).Observed.Slug; got == "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("after concurrent use, overlaid 2008-04-01 = %q, want NOT joseph", got)
+ }
+}