perf(awards): build the logbook snapshot once, not once per caller

A field log showed three pulls of the same 123 615 QSOs inside ten seconds, the
Go heap going 725 MB → 2213 MB → 2539 MB. It is released after the idle TTL, so
not a leak — just the same work done three times with all three results alive at
once.

The cache lock was released before the logbook was read, so every caller that
arrived during a build missed the cache and started its own. Opening the Awards
panel does exactly that. One builder at a time now, with the usual re-check
after taking the lock: the second and third caller wait out the seconds they
were going to spend anyway, minus two round trips to the database.

The slice is also sized from the previous build. Growing to 123 000 structs by
doubling copies the whole thing a dozen times and holds the old and the new
array together at each step, on the largest object OpsLog keeps.

Also, the settings panel registry. The hook trap that broke the window when the
power-supply panel was opened was headed off by a comment, and the comment did
not survive contact with the next panel — so it is now structural. PanelHost is
a module-scope component that calls the selected panel and is keyed by section:
hooks inside a panel land in a component context that persists, section changes
remount it so each gets a fresh and consistent hook list, and no panel state
leaks into the next.

Rendering the panels as <Panel /> instead — the obvious refactor — would have
been wrong: they are nested inside SettingsModal and close over its state, so
each parent render makes a new component TYPE and React would unmount and
remount the panel on every keystroke.
This commit is contained in:
2026-08-16 14:40:18 +02:00
parent 7578e49573
commit 37805fe3ed
4 changed files with 166 additions and 21 deletions
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"sync"
"testing"
"time"
)
// The award snapshot must be built ONCE however many callers ask at once.
//
// The cache lock is released before the logbook is read, so every caller that
// arrives during a build used to miss the cache and start its own. Opening the
// Awards panel does exactly that: a field log showed three pulls of the same
// 123 615 QSOs inside ten seconds, and three copies alive together took the Go
// heap from 725 MB to 2.5 GB.
//
// This models the same shape — a cheap cache check, a slow build, a shared
// result — against the pattern awardSnapshot now uses, so the invariant is
// pinned without needing a logbook.
func TestSnapshotBuildsOncePerRevision(t *testing.T) {
var (
cacheMu sync.Mutex
buildMu sync.Mutex
cached []int
rev = "r1"
gotRev string
builds int
)
get := func() []int {
cacheMu.Lock()
if cached != nil && gotRev == rev {
defer cacheMu.Unlock()
return cached
}
cacheMu.Unlock()
buildMu.Lock()
defer buildMu.Unlock()
// Re-check: whoever held the build lock has just finished.
cacheMu.Lock()
if cached != nil && gotRev == rev {
defer cacheMu.Unlock()
return cached
}
cacheMu.Unlock()
time.Sleep(50 * time.Millisecond) // the logbook read
out := []int{1, 2, 3}
cacheMu.Lock()
builds++
cached, gotRev = out, rev
cacheMu.Unlock()
return out
}
var wg sync.WaitGroup
results := make([][]int, 8)
for i := range results {
wg.Add(1)
go func(i int) { defer wg.Done(); results[i] = get() }(i)
}
wg.Wait()
if builds != 1 {
t.Errorf("%d builds for one revision — each concurrent caller pulled the whole logbook again", builds)
}
for i, r := range results {
if len(r) != 3 {
t.Errorf("caller %d got %v", i, r)
}
}
// A new revision must rebuild: the guard is against duplicate work, not
// against a logbook that changed.
cacheMu.Lock()
rev = "r2"
cacheMu.Unlock()
get()
if builds != 2 {
t.Errorf("builds = %d after the revision moved, want 2 — a changed logbook must be re-read", builds)
}
}