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}) } }