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
+39 -3
View File
@@ -766,6 +766,8 @@ type App struct {
liveTableMu sync.Mutex // guards liveTableFor
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnapBuild sync.Mutex // serialises BUILDING it — see awardSnapshot
awardSnapCap int // rows the last build produced, the capacity hint for the next
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
awardSnapUsed time.Time // last read — the snapshot is dropped once it goes cold (see awardSnapshotJanitor)
@@ -4348,8 +4350,41 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
a.awardSnapMu.Unlock()
}
// ONE BUILDER AT A TIME.
//
// The cache lock above is released before the pull, so every caller that
// arrives while the logbook is being read used to miss and start its own.
// Opening the Awards panel does that: a field log showed three pulls of the
// same 123 615 QSOs within ten seconds, and three copies alive at once took
// the heap from 725 MB to 2.5 GB. The work was identical each time.
//
// Waiting here costs the second and third caller the seconds the first was
// going to take anyway — they were already paying that, plus a second and
// third trip to the database.
a.awardSnapBuild.Lock()
defer a.awardSnapBuild.Unlock()
// Re-check: whoever held the build lock has just finished, and their result
// is what we came for.
if revErr == nil {
a.awardSnapMu.Lock()
if a.awardSnap != nil && a.awardSnapRev == rev {
qs := a.awardSnap
a.awardSnapUsed = time.Now()
a.awardSnapMu.Unlock()
return qs, nil
}
a.awardSnapMu.Unlock()
}
t0 := time.Now()
var all []qso.QSO
// Sized from the last build. Growing a slice to 123 000 structs by doubling
// copies the whole thing a dozen times and holds the old and the new array
// together at every step — on the biggest object OpsLog keeps, that transient
// is worth avoiding.
a.awardSnapMu.Lock()
hint := a.awardSnapCap
a.awardSnapMu.Unlock()
all := make([]qso.QSO, 0, hint)
if err := a.qso.IterateForAwards(a.ctx, func(q qso.QSO) error {
a.enrichQSOForAwards(&q)
all = append(all, q)
@@ -4365,13 +4400,14 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s) — go heap now %d MB",
len(all), time.Since(t0).Round(time.Millisecond), rev, ms.HeapAlloc/(1024*1024))
a.awardSnapMu.Lock()
a.awardSnapCap = len(all) // next build starts the right size
if revErr == nil {
a.awardSnapMu.Lock()
a.awardSnap = all
a.awardSnapRev = rev
a.awardSnapUsed = time.Now()
a.awardSnapMu.Unlock()
}
a.awardSnapMu.Unlock()
return all, nil
}