perf(awards): release the logbook snapshot once it goes cold

Opening the Awards panel pulls every QSO into a cached slice and kept it for the rest of the session: the cache was only ever invalidated by a logbook change, never by disuse. Each cached QSO is a 1896-byte struct plus its strings AND a decoded map of its ADIF extras - one map allocation per QSO. Harmless at 30k rows, several hundred megabytes at 132k, which is where it was reported. A janitor drops it after 15 minutes without a reader and calls FreeOSMemory, because Go hands pages back lazily and the whole point is that the operator sees the memory return. 15 minutes is deliberately generous: an awards session recomputes every few seconds and re-pulling a large remote logbook costs seconds. The heap size is now logged when the snapshot is built and when it is released - a memory report was unanswerable without a number.
This commit is contained in:
2026-08-09 16:57:12 +02:00
parent b10a867125
commit 57e98139ab
2 changed files with 53 additions and 2 deletions
+51 -2
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"runtime/debug"
"sort"
"strconv"
@@ -688,6 +689,7 @@ type App struct {
awardSnapMu sync.Mutex // guards the award QSO snapshot
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)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
// shuttingDown gates beforeClose re-entry: the first user attempt to
@@ -1235,6 +1237,7 @@ func (a *App) startup(ctx context.Context) {
// behind telnet).
a.clusterEvents = newClusterQueue()
go a.clusterEventWorker()
go a.awardSnapshotJanitor() // give the award snapshot's memory back once it goes cold
a.cluster = cluster.NewManager(
// onSpot / onLine run on the session's socket-read goroutine, so they must
@@ -4172,6 +4175,7 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
a.awardSnapMu.Lock()
if a.awardSnap != nil && a.awardSnapRev == rev {
qs := a.awardSnap
a.awardSnapUsed = time.Now()
a.awardSnapMu.Unlock()
return qs, nil
}
@@ -4187,18 +4191,63 @@ func (a *App) awardSnapshot() ([]qso.QSO, error) {
}); err != nil {
return nil, err
}
applog.Printf("awardSnapshot: pulled %d qsos from logbook in %v (rev=%s)",
len(all), time.Since(t0).Round(time.Millisecond), rev)
// Heap alongside the row count: this snapshot is the single largest thing
// OpsLog holds, and "OpsLog is eating memory" reports are unanswerable
// without a number. A 132 000-QSO logbook was the case that prompted it.
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
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))
if revErr == nil {
a.awardSnapMu.Lock()
a.awardSnap = all
a.awardSnapRev = rev
a.awardSnapUsed = time.Now()
a.awardSnapMu.Unlock()
}
return all, nil
}
// awardSnapIdleTTL is how long the snapshot survives without a reader.
//
// Generous on purpose: an operator working through the Awards panel triggers a
// computation every few seconds, and re-pulling costs seconds on a big remote
// logbook. This is only meant to catch the far commoner case — awards looked at
// once, then hours of logging with several hundred megabytes still held.
const awardSnapIdleTTL = 15 * time.Minute
// awardSnapshotJanitor drops the award snapshot once nothing has read it for a
// while, and returns the memory to the OS.
//
// The snapshot is a whole logbook of QSO structs, each carrying a decoded map of
// its ADIF extras: ~1.9 KB of struct plus strings and one map allocation per
// QSO. At 30 000 QSOs that is tens of megabytes and nobody notices; at 132 000
// it is several hundred, held for the rest of the session because the cache had
// no expiry — only invalidation when the logbook changed.
func (a *App) awardSnapshotJanitor() {
for {
time.Sleep(time.Minute)
a.awardSnapMu.Lock()
n := len(a.awardSnap)
idle := !a.awardSnapUsed.IsZero() && time.Since(a.awardSnapUsed) > awardSnapIdleTTL
if a.awardSnap != nil && idle {
a.awardSnap = nil
a.awardSnapRev = ""
}
a.awardSnapMu.Unlock()
if n > 0 && idle {
// FreeOSMemory, not just GC: Go hands pages back lazily, and the whole
// point here is that the operator sees the memory come back.
debug.FreeOSMemory()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
applog.Printf("awardSnapshot: released %d cached qsos after %v idle — go heap now %d MB",
n, awardSnapIdleTTL, ms.HeapAlloc/(1024*1024))
}
}
}
// GetAwardStats computes the worked/confirmed/validated reference counts of one
// award, broken down by band and by mode category (All/CW/Digital/Phone).
func (a *App) GetAwardStats(code string) (AwardStatsResult, error) {