diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-24 21:42:48 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-24 21:42:48 +0200 |
| commit | ea6e59862dbdb607b9bcb221212a85ee3e4bd84a (patch) | |
| tree | 6274d5bfd46663e3e80b6ef04564d57f5e71f646 /internal/i18n/lang | |
| parent | b6ee8f09ad2c0bc9e83394e2427de861eb1e2a72 (diff) | |
| download | lectio-ea6e59862dbdb607b9bcb221212a85ee3e4bd84a.tar.gz lectio-ea6e59862dbdb607b9bcb221212a85ee3e4bd84a.zip | |
perf(calendar): memoise the EF transfer plan across a shared calendar
computeEF calls efTransferPlan once per day, but the plan is pure in
(year, the merged sanctoral content, Selection) and identical for every
day sharing those three -- e.g. every date in one mobile.Days week.
Rebuilding it per day was a real cost: efTransferPlan walks every
merged entry looking for class-1 candidates, and for each one it
considers, occupiedByClass1/Or2 (an occurrence check it also uses)
walks merged AGAIN -- confirmed by CPU profile, not just by reading the
code (github.com/lukaszkasprzak/lectio/internal/calendar.computeEF.func1,
the occupiedByRank closure, at ~62% of BenchmarkDays7EF's total time
before this fix, almost all of it inside buildCelebration).
efTransferPlanCached (transfer_plan_cache.go) wraps efTransferPlan with
a small, bounded, thread-safe LRU (container/list + sync.Mutex, capped
at 64 entries -- gomobile may call in from multiple goroutines, and an
unbounded map keyed by year would grow as a user scrolls through
decades). The cache key is (year, Selection, a SHA-256 of merged's full
content): merged is a map, so it cannot be a map key field itself, and
Go's randomised map iteration order means two calls with identical
content can visit it differently, so the hash sorts slugs and, within
each entry, its Fields/Variant keys before hashing, and covers every
field of every entry -- not just Rank/Date, the ones efTransferPlan's
own read path happens to touch today, because which entries even
qualify as class-1 is itself computed from that data, and
occupiedByClass1/Or2 scan ALL of merged, not just the class-1 subset.
Selection is included even though EF date resolution ignores it today
(resolveDate never reads its sel parameter) -- keying on it costs
nothing (four small strings) and protects a future change from
silently poisoning a cache that never accounted for it. The returned
map is always a fresh copy (clonePlan), never the cached instance, so
sharing it across goroutines needs no further synchronisation.
Verified the key is complete rather than trusted: with the content hash
temporarily dropped from the key (mutation test, not committed),
TestEFTransferPlanCacheInvalidatesOnOverlay failed immediately -- a
plan warmed for the shipped 2008 calendar was wrongly served back for
the same year with a user overlay applied (the Annunciation suppressed,
which changes where the RG 96(a)/97/98 collision sends St Joseph: 31
March instead of 1 April, empirically confirmed against the pre-cache
code before the test was written). TestEFTransferPlanCacheInvalidatesOnYear
is a lighter companion covering the year field. TestEFTransferPlanCacheConcurrentUse
hammers the cache from 12 goroutines across two different keys and
reasserts correctness afterward; clean under `go test -race`.
Benchmarked (interleaved before/after, same method as the readings.Prepare
commit, to control for machine thermal drift): BenchmarkDays7EF drops
from ~48-51ms to ~32-35ms/op (allocs 253810 -> 208639, -18%; bytes
28.1MB -> 16.1MB, -43%), roughly a third faster. BenchmarkDaysWeek (OF,
which never calls efTransferPlan at all) is unaffected, ~3.5-4.1ms/op
both before and after -- within noise, confirming this change is
EF-only as intended.
EF remains well outside OF's range (~32ms vs ~4ms), and a fresh CPU
profile after this fix places the dominant remaining cost precisely: it
is the SAME occupiedByClass1/Or2 pattern, but living OUTSIDE
efTransferPlan -- transferIfImpededEF's own fallback path, called once
per day for every class-1 candidate NOT already resolved by the (now
cached) plan, i.e. the ordinarily-unimpeded ones (~15-20 of them),
each triggering another O(len(merged)) scan. That call site was not
part of what this task named, and memoising it is a materially
different change (it is keyed per-candidate, not once per day), so it
is reported here rather than folded into this commit.
Output identity re-verified: a 492-case sweep of mobile.Day/mobile.Days
(both forms, both UI languages, all four corpora, a leap day, the
Sacred Triduum, a Requiem day, Christmas/Pentecost/Assumption/All Souls
windows, and the three Joseph/Annunciation transfer-collision years
this fix specifically touches -- 2008, 2035, 2046) produced
byte-identical (SHA-256-equal) JSON before and after. go test ./... and
make ci (both build tags, oracle/differential suite included) are
green.
Diffstat (limited to 'internal/i18n/lang')
0 files changed, 0 insertions, 0 deletions
